@lanes-sh/link 0.7.2 → 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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- 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
|
+
}
|
package/src/cli/endpoint-url.ts
CHANGED
|
@@ -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 --
|
|
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
|