@lanes-sh/link 0.7.1 → 0.8.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 (157) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/brand.ts +39 -10
  22. package/src/cli/callback-page.ts +37 -104
  23. package/src/cli/commands/auth-dispatch.ts +48 -0
  24. package/src/cli/commands/auth.ts +229 -0
  25. package/src/cli/commands/connect/accounts.ts +4 -4
  26. package/src/cli/commands/connect/authorise.ts +4 -4
  27. package/src/cli/commands/connect/bind-credential.ts +2 -1
  28. package/src/cli/commands/connect/custom/index.ts +1 -1
  29. package/src/cli/commands/connect/custom/write.ts +2 -2
  30. package/src/cli/commands/connect/grant.ts +29 -14
  31. package/src/cli/commands/connect/index.ts +88 -87
  32. package/src/cli/commands/connect/options.ts +83 -0
  33. package/src/cli/commands/connect/registration.ts +50 -0
  34. package/src/cli/commands/connect/requirements.ts +1 -1
  35. package/src/cli/commands/connect/settle.ts +4 -2
  36. package/src/cli/commands/connect/target-note.ts +7 -2
  37. package/src/cli/commands/connect/unknown.ts +1 -1
  38. package/src/cli/commands/connect/variables.ts +3 -2
  39. package/src/cli/commands/connection-list.ts +116 -0
  40. package/src/cli/commands/connection.ts +182 -165
  41. package/src/cli/commands/grant.ts +140 -0
  42. package/src/cli/commands/identity.ts +21 -9
  43. package/src/cli/commands/knowledge/index.ts +46 -79
  44. package/src/cli/commands/knowledge/migrate.ts +74 -13
  45. package/src/cli/commands/knowledge/show.ts +92 -0
  46. package/src/cli/commands/knowledge.ts +2 -1
  47. package/src/cli/commands/mcp/harnesses.ts +30 -8
  48. package/src/cli/commands/mcp/onboarding.ts +86 -0
  49. package/src/cli/commands/mcp/register.ts +16 -2
  50. package/src/cli/commands/mcp.ts +1 -0
  51. package/src/cli/commands/members.ts +288 -0
  52. package/src/cli/commands/operate/attach.ts +3 -3
  53. package/src/cli/commands/operate/audit.ts +11 -7
  54. package/src/cli/commands/operate/auth.ts +28 -11
  55. package/src/cli/commands/operate/findings.ts +2 -1
  56. package/src/cli/commands/operate/inspect.ts +37 -19
  57. package/src/cli/commands/operate/migrate.ts +29 -12
  58. package/src/cli/commands/operate/outputs.ts +3 -3
  59. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  60. package/src/cli/commands/operate/pair.ts +324 -0
  61. package/src/cli/commands/operate/policy.ts +73 -22
  62. package/src/cli/commands/operate/serve.ts +52 -4
  63. package/src/cli/commands/operate/status.ts +18 -10
  64. package/src/cli/commands/operate/tools.ts +2 -2
  65. package/src/cli/commands/operate.ts +2 -0
  66. package/src/cli/commands/owner/shared.ts +13 -2
  67. package/src/cli/commands/owner/skills.ts +28 -8
  68. package/src/cli/commands/profile/removal.ts +79 -76
  69. package/src/cli/commands/profile/remove.ts +16 -1
  70. package/src/cli/commands/profile.ts +46 -10
  71. package/src/cli/commands/relabel.ts +112 -0
  72. package/src/cli/commands/secrets.ts +34 -12
  73. package/src/cli/commands/set-workspace.ts +96 -0
  74. package/src/cli/commands/setup.ts +2 -2
  75. package/src/cli/commands/sync.ts +8 -8
  76. package/src/cli/commands/target.ts +9 -7
  77. package/src/cli/commands/update.ts +58 -17
  78. package/src/cli/config-edit.ts +75 -140
  79. package/src/cli/config-migrate.ts +82 -64
  80. package/src/cli/config-repair.ts +89 -32
  81. package/src/cli/config-templates.ts +198 -0
  82. package/src/cli/contract3-data.ts +328 -0
  83. package/src/cli/contract3-shape.ts +186 -0
  84. package/src/cli/contract3.ts +282 -0
  85. package/src/cli/endpoint-url.ts +1 -1
  86. package/src/cli/lanes.ts +25 -1
  87. package/src/cli/main.ts +89 -14
  88. package/src/cli/migrate-plan.ts +12 -6
  89. package/src/cli/output.ts +34 -1
  90. package/src/cli/publish.ts +5 -2
  91. package/src/cli/runtime/open.ts +63 -98
  92. package/src/cli/runtime/registry.ts +6 -7
  93. package/src/cli/runtime/stores.ts +53 -0
  94. package/src/cli/runtime/types.ts +106 -0
  95. package/src/cli/runtime/vault.ts +19 -4
  96. package/src/cli/runtime/workspace.ts +60 -0
  97. package/src/cli/runtime.ts +2 -1
  98. package/src/cli/selection-require.ts +44 -13
  99. package/src/cli/selection.ts +127 -145
  100. package/src/cli/usage.ts +34 -18
  101. package/src/cli/workspace-migrate.ts +125 -16
  102. package/src/connectivity/manifest/provider.ts +3 -1
  103. package/src/connectivity/manifest/requirements.ts +1 -1
  104. package/src/deployments/bind.ts +1 -1
  105. package/src/deployments/deploy.ts +36 -27
  106. package/src/deployments/gcp/bucket.ts +18 -7
  107. package/src/deployments/gcp/provision.ts +7 -7
  108. package/src/deployments/prepare.ts +72 -24
  109. package/src/deployments/record.ts +1 -1
  110. package/src/deployments/report.ts +2 -2
  111. package/src/deployments/serving.ts +15 -74
  112. package/src/deployments/target.ts +15 -15
  113. package/src/deployments/upload.ts +46 -22
  114. package/src/dispatch/deps.ts +88 -0
  115. package/src/dispatch/dispatch.ts +21 -62
  116. package/src/policy/index.ts +47 -15
  117. package/src/profile/connections.ts +183 -0
  118. package/src/profile/deployments.ts +3 -3
  119. package/src/profile/index.ts +30 -5
  120. package/src/profile/layout.ts +86 -89
  121. package/src/profile/load.ts +80 -47
  122. package/src/profile/pairing.ts +32 -0
  123. package/src/profile/primitives.ts +35 -1
  124. package/src/profile/registry.ts +6 -6
  125. package/src/profile/schema.ts +172 -21
  126. package/src/profile/targets.ts +21 -9
  127. package/src/profile/testing.ts +69 -2
  128. package/src/profile/workspace.ts +58 -3
  129. package/src/providers/custom/index.ts +1 -1
  130. package/src/providers/custom/load.ts +2 -3
  131. package/src/providers/identity/provider.ts +1 -1
  132. package/src/providers/memory/provider.ts +20 -2
  133. package/src/providers/setup/plan.ts +1 -1
  134. package/src/providers/slack/index.ts +2 -2
  135. package/src/registry/policy-bridge.ts +33 -11
  136. package/src/registry/reconcile.ts +4 -4
  137. package/src/server/authorization.ts +94 -0
  138. package/src/server/edge.ts +14 -1
  139. package/src/server/endpoint.ts +85 -104
  140. package/src/server/generation.ts +10 -1
  141. package/src/server/harness.ts +71 -13
  142. package/src/server/index.ts +31 -0
  143. package/src/server/mcp/build.ts +20 -1
  144. package/src/server/mcp/client-info.ts +54 -0
  145. package/src/server/mcp/guide.ts +120 -0
  146. package/src/server/mcp/instructions.ts +1 -1
  147. package/src/server/mcp/prompts.ts +7 -3
  148. package/src/server/mcp/resources.ts +16 -8
  149. package/src/server/mcp/tools.ts +9 -3
  150. package/src/server/mcp/visibility.ts +18 -3
  151. package/src/server/oauth.ts +29 -75
  152. package/src/server/read/credential.ts +134 -0
  153. package/src/server/read/deployed.ts +56 -0
  154. package/src/server/read/listener.ts +54 -0
  155. package/src/server/read/open.ts +101 -0
  156. package/src/server/read/routes.ts +247 -0
  157. package/src/server/read/state.ts +171 -0
@@ -0,0 +1,186 @@
1
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
+ import type { ContractRename, LegacyConnection, LegacyProfile } from './contract3.ts';
3
+ import { keyOf } from './contract3.ts';
4
+
5
+ /**
6
+ * Turning contract 2's shape into contract 3's, without touching a file.
7
+ *
8
+ * Split from the flow because these are the decisions and that is the ordering:
9
+ * which connections exist after hoisting, what each profile's grants become, and
10
+ * what a rule with an expiry means. Every defect review found in this migration
11
+ * was in one of those three, and none of them needed a filesystem to reproduce.
12
+ */
13
+
14
+ /**
15
+ * Hoist every profile's connections into one list.
16
+ *
17
+ * **Keyed on provider and account, not on the id.** The common case is two
18
+ * profiles that both connected the same mailbox: same provider, same account,
19
+ * usually the same id, and they merge into one row because they *are* one
20
+ * account. The interesting case is two profiles each holding a row spelled
21
+ * `gmail.main` naming different mailboxes, which is legal under contract 2
22
+ * because a connection lived inside one profile and nothing ever compared them.
23
+ *
24
+ * That collision is resolved by renaming, never by picking. Both accounts are
25
+ * real, both have a credential, and choosing either would take somebody's
26
+ * mailbox away silently. The second becomes `gmail.main_2`, and the rename is
27
+ * reported so the operator sees it before anything else reads the file.
28
+ */
29
+ export function hoistConnections(profiles: ReadonlyMap<string, LegacyProfile>): {
30
+ rows: LegacyConnection[];
31
+ renames: ContractRename[];
32
+ perProfile: Map<string, Map<string, string>>;
33
+ } {
34
+ const rows: LegacyConnection[] = [];
35
+ const renames: ContractRename[] = [];
36
+ const byAccount = new Map<string, LegacyConnection>();
37
+ const taken = new Set<string>();
38
+ const perProfile = new Map<string, Map<string, string>>();
39
+
40
+ for (const [profile, config] of profiles) {
41
+ const mapping = new Map<string, string>();
42
+ perProfile.set(profile, mapping);
43
+
44
+ for (const connection of config.connections ?? []) {
45
+ // Two profiles' owner layers are never the same thing, whatever their
46
+ // rows say. Every contract-2 profile carried an identical owner layer
47
+ // written from a fixed table — `{memory: 'Memory', vault: 'Vault', ...}`
48
+ // — so an identity of provider-plus-account made all of them collide and
49
+ // merge. That is the one outcome ADR-059 forbids: interleaving two sets of
50
+ // notes is not reversible and not reviewable, and for the vault the wrong
51
+ // answer is a credential. Keying on the profile forces a rename instead.
52
+ const owner = RESERVED_PROVIDER_IDS.includes(connection.provider);
53
+ const identity = owner
54
+ ? `${connection.provider} @${profile}`
55
+ : `${connection.provider} ${connection.account}`;
56
+ const existing = byAccount.get(identity);
57
+
58
+ if (existing) {
59
+ // The same account, already hoisted. This profile's old key maps to
60
+ // whatever the first one settled on, which may itself be a rename.
61
+ mapping.set(keyOf(connection), keyOf(existing));
62
+ continue;
63
+ }
64
+
65
+ let id = connection.id;
66
+ if (taken.has(`${connection.provider}.${id}`)) {
67
+ // The profile's own name for an owner-layer surface, which is what
68
+ // ADR-059 specifies and reads far better than `memory.main_2` when the
69
+ // thing being separated is "work's notes". A numeric suffix is the
70
+ // fallback for a real account, and for the case where the profile name
71
+ // is itself taken.
72
+ const preferred = owner ? sanitise(profile) : `${id}_2`;
73
+ let candidate = preferred;
74
+ let suffix = 2;
75
+ while (taken.has(`${connection.provider}.${candidate}`)) {
76
+ suffix += 1;
77
+ candidate = `${preferred}_${suffix}`;
78
+ }
79
+ renames.push({
80
+ from: `${connection.provider}.${id}`,
81
+ to: `${connection.provider}.${candidate}`,
82
+ reason: owner
83
+ ? `"${profile}" has its own ${connection.provider}, which is not "${id}"'s`
84
+ : `"${profile}" named a different account (${connection.account}) with that id`,
85
+ });
86
+ id = candidate;
87
+ }
88
+
89
+ const row: LegacyConnection = { ...connection, id };
90
+ rows.push(row);
91
+ byAccount.set(identity, row);
92
+ taken.add(keyOf(row));
93
+ mapping.set(keyOf(connection), keyOf(row));
94
+ }
95
+ }
96
+
97
+ return { rows, renames, perProfile };
98
+ }
99
+
100
+ /** A profile name as a connection id: the same alphabet `connectionRef` allows. */
101
+ function sanitise(profile: string): string {
102
+ const cleaned = profile.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/^_+/, '');
103
+ return cleaned.length > 0 ? cleaned : 'two';
104
+ }
105
+
106
+ /**
107
+ * A rule, in whichever of the two shapes contract 2 accepted, with its expiry.
108
+ *
109
+ * Reading the capability alone got both directions wrong, and it is the same
110
+ * mistake `config-repair.ts` documents. `isRuleActive` is what made a lapsed
111
+ * rule inert, so dropping `expires_at` turned an allow that died months ago into
112
+ * a live permanent grant — and an expired *deny* into a permanent one, which is
113
+ * not the safe direction either, because a deny outranks every allow.
114
+ *
115
+ * A rule that has already lapsed is dropped rather than carried: it granted and
116
+ * denied nothing on the day of the migration, and writing it forward would give
117
+ * it a meaning it did not have.
118
+ */
119
+ type Rule = string | { capability: string; expires_at: string };
120
+
121
+ function patternsOf(rules: unknown, now = Date.now()): Rule[] {
122
+ if (!Array.isArray(rules)) return [];
123
+
124
+ return rules.flatMap((rule): Rule[] => {
125
+ if (typeof rule === 'string') return [rule];
126
+
127
+ const object = rule as { capability?: unknown; expires_at?: unknown } | null;
128
+ const capability = object?.capability;
129
+ if (typeof capability !== 'string') return [];
130
+
131
+ const expiry = object?.expires_at;
132
+ if (typeof expiry !== 'string') return [capability];
133
+
134
+ const at = Date.parse(expiry);
135
+ if (Number.isNaN(at)) return [capability];
136
+ return at > now ? [{ capability, expires_at: expiry }] : [];
137
+ });
138
+ }
139
+
140
+ /** The capability a rule names, whichever shape it is in. */
141
+ function capabilityOf(rule: Rule): string {
142
+ return typeof rule === 'string' ? rule : rule.capability;
143
+ }
144
+
145
+ /**
146
+ * The grant rows one contract-2 profile becomes.
147
+ *
148
+ * Every connection gets the rules that named its provider, which is precisely
149
+ * what the flat block meant: rules covered every account of a provider in the
150
+ * profile. So this loses nothing, and gains the ability to diverge afterwards.
151
+ *
152
+ * A rule naming a provider the profile has no connection for is dropped rather
153
+ * than carried. Under contract 2 an `allow` like that was refused at load, and a
154
+ * `deny` was permitted as a note to self; there is nowhere to put either now,
155
+ * because a row without a connection is not expressible.
156
+ */
157
+ export function grantsFor(
158
+ config: LegacyProfile,
159
+ mapping: ReadonlyMap<string, string>,
160
+ ): { connection: string; allow: Rule[]; deny: Rule[] }[] {
161
+ const allow = patternsOf(config.policy?.allow);
162
+ const deny = patternsOf(config.policy?.deny);
163
+
164
+ const covers = (rule: Rule, provider: string): boolean => {
165
+ const pattern = capabilityOf(rule);
166
+ return pattern === '*' || pattern.startsWith(`${provider}.`);
167
+ };
168
+
169
+ return (config.connections ?? []).map((connection) => {
170
+ const provider = connection.provider;
171
+ // A bare `*` becomes the provider wildcard rather than being copied
172
+ // through. It would still mean the same thing inside a row, which is
173
+ // already scoped to one connection, but writing it out is what makes the
174
+ // file say so. An expiry rides along untouched.
175
+ const widen = (rule: Rule): Rule => {
176
+ if (typeof rule === 'string') return rule === '*' ? `${provider}.*` : rule;
177
+ return rule.capability === '*' ? { ...rule, capability: `${provider}.*` } : rule;
178
+ };
179
+
180
+ return {
181
+ connection: mapping.get(keyOf(connection)) ?? keyOf(connection),
182
+ allow: allow.filter((rule) => covers(rule, provider)).map(widen),
183
+ deny: deny.filter((rule) => covers(rule, provider)).map(widen),
184
+ };
185
+ });
186
+ }
@@ -0,0 +1,282 @@
1
+ import { newConnectionsTemplate } from './config-templates.ts';
2
+ import { applyMoves, mergeCredentials, planCredentials, planMoves, type Move } from './contract3-data.ts';
3
+ import { parseDocument } from 'yaml';
4
+ import {
5
+ CONNECTIONS_FILE,
6
+ ConfigError,
7
+ DATA_DIR,
8
+ WORKSPACE_FILE,
9
+ layout,
10
+ listProfiles,
11
+ readWorkspaceFile,
12
+ workspaceFiles,
13
+ writeWorkspaceFile,
14
+ } from '#profile';
15
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
16
+ import { ConfigDocument } from './config-edit.ts';
17
+ import { grantsFor, hoistConnections } from './contract3-shape.ts';
18
+
19
+ /**
20
+ * Contract 2 to contract 3: connections move out of the profile.
21
+ *
22
+ * The hardest migration this project has done, and the one it could least
23
+ * afford to skip. `layout.ts` and ADR-030 both set the precedent that there is
24
+ * no migration, because machinery to move an old layout is more code than the
25
+ * thing it moves and has to keep working forever. That precedent was set for
26
+ * *empty directories*. Here the thing being moved is every credential the
27
+ * operator holds, and "re-authorise fifteen accounts in a browser" is not a
28
+ * release note anyone should write.
29
+ *
30
+ * Four moves, in an order chosen so a crash between any two leaves a workspace
31
+ * that still opens:
32
+ *
33
+ * 1. Connections are hoisted into `connections.yaml`.
34
+ * 2. Credentials are merged into one store, and read back before the old ones
35
+ * are touched.
36
+ * 3. Profiles are rewritten, `grants:` being the old connections crossed with
37
+ * the old flat policy, which is exactly what contract 2 meant.
38
+ * 4. Bytes move to their connection-keyed homes.
39
+ *
40
+ * The registry rename rides along at the end, because it is the one step that
41
+ * cannot half-apply: it is a single document.
42
+ */
43
+
44
+ /** What a divergent id was renamed to, and why. */
45
+ export interface ContractRename {
46
+ readonly from: string;
47
+ readonly to: string;
48
+ readonly reason: string;
49
+ }
50
+
51
+ export interface Contract3Migration {
52
+ readonly workspaceRoot: string;
53
+ readonly profiles: readonly string[];
54
+ readonly connections: readonly string[];
55
+ readonly renames: readonly ContractRename[];
56
+ readonly credentials: readonly string[];
57
+ readonly moved: readonly string[];
58
+ readonly changes: readonly string[];
59
+ readonly alreadyCurrent: boolean;
60
+ }
61
+
62
+ export interface LegacyConnection {
63
+ readonly id: string;
64
+ readonly provider: string;
65
+ readonly account: string;
66
+ readonly label?: string;
67
+ readonly credential_ref?: string;
68
+ readonly config?: Record<string, unknown>;
69
+ }
70
+
71
+ export interface LegacyProfile {
72
+ readonly contract?: number;
73
+ readonly connections?: LegacyConnection[];
74
+ readonly policy?: { allow?: unknown[]; deny?: unknown[] };
75
+ readonly oauth_apps?: Record<string, unknown>;
76
+ }
77
+
78
+ /** Whether this workspace still holds anything at contract 2. */
79
+ export async function needsContract3(workspaceRoot: string): Promise<boolean> {
80
+ for (const profile of await listProfiles(workspaceRoot)) {
81
+ const raw = await readProfile(workspaceRoot, profile);
82
+ if (raw !== null && (raw.contract ?? 0) === 2) return true;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ async function readProfile(root: string, profile: string): Promise<LegacyProfile | null> {
88
+ const text = await readWorkspaceFile(workspaceFiles(root), `profiles/${profile}.yaml`);
89
+ if (text === null) return null;
90
+ try {
91
+ return parseDocument(text).toJSON() as LegacyProfile;
92
+ } catch {
93
+ // A file that will not parse is not this function's problem to report;
94
+ // `check` gives it a better sentence than "needs migrating" would.
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /** `gmail.main` — how a connection is addressed in every file after this. */
100
+ export function keyOf(connection: { provider: string; id: string }): string {
101
+ return `${connection.provider}.${connection.id}`;
102
+ }
103
+
104
+ export async function migrateToContract3(
105
+ workspaceRoot: string,
106
+ options: { apply: boolean; subject?: string } = { apply: true },
107
+ ): Promise<Contract3Migration> {
108
+ const legacy = new Map<string, LegacyProfile>();
109
+
110
+ for (const profile of await listProfiles(workspaceRoot)) {
111
+ const raw = await readProfile(workspaceRoot, profile);
112
+ if (raw !== null && (raw.contract ?? 0) === 2) legacy.set(profile, raw);
113
+ }
114
+
115
+ const nothing: Contract3Migration = {
116
+ workspaceRoot,
117
+ profiles: [],
118
+ connections: [],
119
+ renames: [],
120
+ credentials: [],
121
+ moved: [],
122
+ changes: [],
123
+ alreadyCurrent: true,
124
+ };
125
+ if (legacy.size === 0) return nothing;
126
+
127
+ const { rows, renames, perProfile } = hoistConnections(legacy);
128
+ const files = workspaceFiles(workspaceRoot);
129
+
130
+ // Everything that can be computed is computed before the first write, so a
131
+ // refusal leaves the workspace exactly as it was.
132
+ const credentials = await planCredentials(workspaceRoot, [...legacy.keys()]);
133
+ const moves = await planMoves(files, [...legacy.keys()], perProfile);
134
+
135
+ const changes: string[] = [
136
+ `${CONNECTIONS_FILE}: ${rows.length} connection(s) hoisted`,
137
+ ...renames.map((rename) => `renamed ${rename.from} to ${rename.to} (${rename.reason})`),
138
+ ...[...legacy.keys()].map((profile) => `profiles/${profile}.yaml: contract 3, grants`),
139
+ ];
140
+ if (credentials.length > 0) {
141
+ changes.push(`${layout.credentials()}: ${credentials.length} credential(s) merged`);
142
+ }
143
+ if (moves.length > 0) changes.push(`${moves.length} object(s) moved to their connection`);
144
+
145
+ const result: Contract3Migration = {
146
+ workspaceRoot,
147
+ profiles: [...legacy.keys()],
148
+ connections: rows.map(keyOf),
149
+ renames,
150
+ credentials,
151
+ moved: moves.map((move) => move.to),
152
+ changes,
153
+ alreadyCurrent: false,
154
+ };
155
+
156
+ if (!options.apply) return result;
157
+
158
+ // The registry first, and this ordering is the whole of the re-entrancy.
159
+ //
160
+ // Every later step is idempotent — writing `connections.yaml` again produces
161
+ // the same file, merging a credential that is already there is a no-op,
162
+ // rewriting a contract-3 profile is skipped, and a move whose source is gone
163
+ // is skipped. `rewriteRegistry` is the one step that is not, because it reads
164
+ // `targets:` and would find none the second time.
165
+ //
166
+ // Running it last meant an interruption anywhere before it left profiles at
167
+ // contract 3 and `lanes-link.yaml` still at contract 2 — a state where
168
+ // re-entry found nothing to migrate, and `workspaceSchema` parsed a file whose
169
+ // `workspaces:` defaulted to empty, so every command refused with "declares no
170
+ // workspace" and there was no way back.
171
+ await rewriteRegistry(workspaceRoot);
172
+ await writeConnections(workspaceRoot, rows, legacy);
173
+ await mergeCredentials(workspaceRoot, [...legacy.keys()]);
174
+
175
+ // **The bytes move before the profile says they have.**
176
+ //
177
+ // `rewriteProfiles` is what stamps `contract: 3`, and that stamp is the only
178
+ // thing `needsContract3` reads — so it is not a step among steps, it is the
179
+ // record that the migration finished. Running it before `applyMoves` meant an
180
+ // interruption between the two left profiles claiming contract 3 with every
181
+ // byte still under `data/<profile>/`, and a re-run that looked at the stamp
182
+ // and found nothing to do. The workspace opened, which is what this file
183
+ // ordered its steps to guarantee, and the owner's memory, tasks, skills and
184
+ // audit log were not in it.
185
+ //
186
+ // A network round trip per object made that window real rather than
187
+ // theoretical: this migrates buckets now, and the first one it was pointed at
188
+ // held 1,906 objects.
189
+ await applyMoves(files, moves);
190
+ await rewriteProfiles(workspaceRoot, legacy, perProfile, options.subject);
191
+
192
+ return result;
193
+ }
194
+
195
+ /** The hoisted rows, plus every profile's `oauth_apps` merged into one block. */
196
+ async function writeConnections(
197
+ root: string,
198
+ rows: readonly LegacyConnection[],
199
+ legacy: ReadonlyMap<string, LegacyProfile>,
200
+ ): Promise<void> {
201
+ const apps: Record<string, unknown> = {};
202
+ for (const config of legacy.values()) Object.assign(apps, config.oauth_apps ?? {});
203
+
204
+ const document = ConfigDocument.fromText(newConnectionsTemplate(), CONNECTIONS_FILE);
205
+ document.setIn(['connections'], rows);
206
+ document.setIn(['oauth_apps'], apps);
207
+
208
+ await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, document.toString());
209
+ }
210
+
211
+ /** Contract 3, `grants:` and `members:`, with everything else left as written. */
212
+ async function rewriteProfiles(
213
+ root: string,
214
+ legacy: ReadonlyMap<string, LegacyProfile>,
215
+ perProfile: ReadonlyMap<string, Map<string, string>>,
216
+ subject: string | undefined,
217
+ ): Promise<void> {
218
+ for (const [profile, config] of legacy) {
219
+ const document = await ConfigDocument.open(root, profile);
220
+
221
+ document.setIn(['contract'], 3);
222
+ document.setIn(['grants'], grantsFor(config, perProfile.get(profile) ?? new Map()));
223
+ // Empty unless the migration was run by somebody signed in. Nobody is a
224
+ // legitimate state and it is default deny on the identity axis, but a
225
+ // workspace whose profiles nobody can consume is a poor thing to hand back,
226
+ // so `update` passes the signed-in subject through.
227
+ document.setIn(['members'], subject ? [{ subject, role: 'owner' }] : []);
228
+
229
+ // The authorization block arrives here rather than in the template, because
230
+ // an existing profile has one only if it was deployed. Every endpoint runs
231
+ // the flow now, loopback included (ADR-062).
232
+ if (document.getIn(['auth', 'authorization']) === undefined) {
233
+ document.setIn(['auth', 'authorization'], { mode: 'self' });
234
+ }
235
+
236
+ document.removeIn(['connections']);
237
+ document.removeIn(['policy']);
238
+ document.removeIn(['oauth_apps']);
239
+
240
+ await document.save();
241
+ }
242
+ }
243
+
244
+ /** `targets:` becomes `workspaces:`, and a pointer's `workspace:` becomes `at:`. */
245
+ async function rewriteRegistry(root: string): Promise<void> {
246
+ const document = await ConfigDocument.openKey(root, WORKSPACE_FILE);
247
+ const registry = document.toJSON() as {
248
+ contract?: number;
249
+ targets?: Record<string, { workspace?: string }>;
250
+ } | null;
251
+
252
+ const targets = registry?.targets;
253
+
254
+ // Already renamed, which is what a contract-1 workspace looks like here: the
255
+ // 1-to-2 migration wrote `workspaces:` on its way through. Returning early
256
+ // skipped the contract stamp and the default below, so that path finished a
257
+ // migration and left neither.
258
+ if (targets !== undefined) {
259
+ const workspaces: Record<string, unknown> = {};
260
+ for (const [name, entry] of Object.entries(targets)) {
261
+ const { workspace, ...rest } = entry;
262
+ workspaces[name] = workspace === undefined ? rest : { at: workspace, ...rest };
263
+ }
264
+
265
+ document.setIn(['workspaces'], workspaces);
266
+ document.removeIn(['targets']);
267
+ }
268
+
269
+ document.setIn(['contract'], 3);
270
+ const workspaces = (document.toJSON() as { workspaces?: Record<string, unknown> } | null)
271
+ ?.workspaces ?? {};
272
+
273
+ // The first workspace in the registry, which for every workspace this
274
+ // migration will ever see is `local`. Written rather than left absent so the
275
+ // sticky default is on from the first command after upgrading (ADR-061).
276
+ if (document.getIn(['default_workspace']) === undefined) {
277
+ const first = Object.keys(workspaces)[0];
278
+ if (first !== undefined) document.setIn(['default_workspace'], first);
279
+ }
280
+
281
+ await document.save();
282
+ }
@@ -9,7 +9,7 @@ import type { Config, DeployConfig, TargetConfig } from '#profile';
9
9
  *
10
10
  * Shared because getting it wrong is silent in the worst way. `outputs` asked
11
11
  * the driver; `mcp add` built the local URL unconditionally, so
12
- * `lanes link mcp add --target cloud` registered `http://127.0.0.1:7337/mcp`
12
+ * `lanes link mcp add --workspace cloud` registered `http://127.0.0.1:7337/mcp`
13
13
  * with the agent — a registration that looks successful, names the right
14
14
  * server, and points at a port with nothing behind it. One of the two had the
15
15
  * answer and the other could not see it, which is what a copied line does
package/src/cli/lanes.ts CHANGED
@@ -21,13 +21,23 @@ const AREAS: Record<string, string> = {
21
21
  link: 'a self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets',
22
22
  };
23
23
 
24
+ /** Commands that belong to the binary rather than to any one area. */
25
+ const TOP_LEVEL: Record<string, string> = {
26
+ auth: 'sign in to Lanes, so a profile can say who may use it',
27
+ 'set-workspace': 'which workspace a command means when it does not say',
28
+ };
29
+
24
30
  function areasUsage(): string {
25
31
  const rows = Object.entries(AREAS)
26
32
  .map(([name, blurb]) => ` ${style.bold(`lanes ${name}`)} ${blurb}`)
27
33
  .join('\n');
28
34
 
35
+ const top = Object.entries(TOP_LEVEL)
36
+ .map(([name, blurb]) => ` ${style.bold(`lanes ${name}`)} ${blurb}`)
37
+ .join('\n');
38
+
29
39
  return (
30
- `${style.bold('lanes')} — your own tools, wherever you work\n\n${rows}\n\n` +
40
+ `${style.bold('lanes')} — your own tools, wherever you work\n\n${rows}\n\n${top}\n\n` +
31
41
  `Run ${style.bold('lanes <area> help')} for what an area can do, ` +
32
42
  `or ${style.bold('lanes --version')} for which release this is.\n`
33
43
  );
@@ -51,6 +61,20 @@ async function main(argv: readonly string[]): Promise<void> {
51
61
  return;
52
62
  }
53
63
 
64
+ // A top-level command rather than an area, because it selects the workspace
65
+ // *the CLI* acts in — every area's commands read the same answer (ADR-061).
66
+ if (area === 'auth') {
67
+ const { runAuth } = await import('./commands/auth-dispatch.ts');
68
+ return await runAuth(rest);
69
+ }
70
+
71
+ if (area === 'set-workspace') {
72
+ const { setWorkspace } = await import('./commands/set-workspace.ts');
73
+ const { parseArgv } = await import('./argv.ts');
74
+ const { command, flags } = parseArgv(rest);
75
+ return await setWorkspace(command[0], { json: flags['json'] === true });
76
+ }
77
+
54
78
  if (area === 'link') {
55
79
  // Loaded here rather than at the top of the file so that a failure while
56
80
  // the area's module graph evaluates — a malformed environment variable read