@lanes-sh/link 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,7 @@ that speaks MCP — reaches all of it through that one endpoint. Change your AI
12
12
  context, because none of it ever lived in the agent. Open source, self-hostable, no vendor in the
13
13
  middle of your data.
14
14
 
15
- <picture>
16
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/lanes-link-dark.svg">
17
- <img alt="Claude, Codex, and Gemini all reach one Lanes Link endpoint, which you run yourself. Beneath it sit the profiles it serves: personal, holding Gmail, Memory, and Skills; and work, holding Gmail, Calendar, and Docs." src="docs/images/lanes-link-light.svg">
18
- </picture>
15
+ ![Left: every agent wired directly to every account, each line crossing the others. Right: the same three agents reaching one Lanes Link endpoint, which fans out to Gmail, GitHub, Slack, memory, skills, and a task list through an Access Profile named engineering.](docs/images/lanes-link-hero.png)
19
16
 
20
17
  ## Why
21
18
 
@@ -81,27 +78,6 @@ tool and worth stating plainly; what it is not is a dependency per request. The
81
78
  to sign in and to refresh, and a machine offline for a day keeps serving. `lanes link token
82
79
  show` still mints a static token for CI, which has no browser to sign in with.
83
80
 
84
- ## In the Lanes desktop app
85
-
86
- Prefer not to use a terminal? The [Lanes desktop app](https://lanes.sh/desktop) drives this CLI from
87
- a settings page. **Settings → Integrations → Lanes Link** installs it, holds the profile and
88
- workspace every command runs against, starts and stops the endpoint, and registers it with Claude
89
- Code or Codex. From 0.8.0 your connections, profiles and audit log are on the
90
- [Lanes dashboard](https://lanes.sh/dashboard/link) instead, which reads your endpoint directly over
91
- loopback: run `lanes link pair` once to let it.
92
-
93
- ![The Lanes Link page in the Lanes desktop app: the CLI status card and its version, the workspace and profile selectors, the endpoint row with its running state, and the list of connected accounts.](docs/images/lanes-link-desktop.png)
94
-
95
- It runs the commands above rather than reimplementing them, so consent and the token stay here where
96
- they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
97
- Lanes v0.47.0, as a research preview.
98
-
99
- ```console
100
- $ lanes link desktop # opens the app on that page, installing it if it is not there
101
- ```
102
-
103
- **[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
104
-
105
81
  ## What you keep in it
106
82
 
107
83
  Not "what your agent gets" — the distinction is the whole point. These are yours. An agent is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
package/src/cli/brand.ts CHANGED
@@ -13,17 +13,19 @@
13
13
  * Values are transcribed from https://lanes.sh/design/foundations. The rules
14
14
  * they express, which the CSS below encodes rather than restates:
15
15
  *
16
- * - **Gold is the only accent.** "Gold for positive, neutral tokens otherwise."
17
- * That is why there is no green here: a connection that works is `positive`,
18
- * which is gold, and everything else is one of two neutrals. `--destructive`
19
- * is for errors, not for a state that merely needs attention.
16
+ * - **Emerald is the only accent.** "Accent for positive, neutral tokens
17
+ * otherwise." A connection that works is `positive`, which is the accent, and
18
+ * everything else is one of two neutrals. `--destructive` is for errors, not
19
+ * for a state that merely needs attention. The accent was gold until emerald
20
+ * replaced it site-wide; there is still only one.
20
21
  * - **Two weights, 400 and 500.** Nothing else is available in the loaded faces,
21
22
  * so a heavier rule would silently synthesise.
22
23
  * - **Lora heads, Geist speaks, Geist Mono for code and eyebrow labels.**
23
24
  *
24
- * `--border`, `--accent-gold` and `--destructive` are constant across themes.
25
- * Only the five neutrals swap, which is why the dark block below is five lines
26
- * rather than a second stylesheet.
25
+ * `--border` and `--destructive` are constant across themes. The five neutrals
26
+ * swap, and so does `--accent-brand`: the deep emerald goes illegible on the
27
+ * dark ground, so dark mode takes the brighter value. That is why the dark block
28
+ * below is six lines rather than a second stylesheet.
27
29
  */
28
30
 
29
31
  /**
@@ -61,7 +63,7 @@ export const FONTS =
61
63
 
62
64
  /** The one-row footer, identical to the one the Lanes API's own pages carry. */
63
65
  export const FOOTER =
64
- '<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
66
+ '<div class="footer"><p>Run many agents at once. Connect them to your tools once - ' +
65
67
  '<a href="https://lanes.sh/">lanes.sh</a></p></div>';
66
68
 
67
69
  /**
@@ -85,13 +87,13 @@ export const TOKENS = `
85
87
  --foreground: #171717;
86
88
  --muted-foreground: #202329;
87
89
  --border: rgba(120,113,108,0.2);
88
- --accent-gold: #A1845A;
90
+ --accent-brand: #059669;
89
91
  --destructive: #A06060;
90
92
  /* The two tints the badge variants need. Spelled as rgba rather than
91
93
  color-mix because the consent screen opens in whatever browser a phone
92
94
  happens to use, and both accents are constant across themes anyway. */
93
- --gold-fill: rgba(161,132,90,0.1);
94
- --gold-ring: rgba(161,132,90,0.25);
95
+ --brand-fill: rgba(5,150,105,0.1);
96
+ --brand-ring: rgba(5,150,105,0.25);
95
97
  --destructive-fill: rgba(160,96,96,0.1);
96
98
  --destructive-ring: rgba(160,96,96,0.25);
97
99
  --sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -105,6 +107,9 @@ export const TOKENS = `
105
107
  --muted: #202329;
106
108
  --foreground: #EBEAE7;
107
109
  --muted-foreground: #A8A29E;
110
+ --accent-brand: #34D399;
111
+ --brand-fill: rgba(52,211,153,0.1);
112
+ --brand-ring: rgba(52,211,153,0.25);
108
113
  }
109
114
  }
110
115
  * { box-sizing: border-box; }
@@ -124,21 +129,21 @@ a { color: inherit; }
124
129
  /* Surfaces are dashed and 10px; controls are 6px; pills are round. */
125
130
  .surface { background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
126
131
 
127
- /* The four badge variants, as the design system defines them: gold for
132
+ /* The four badge variants, as the design system defines them: the accent for
128
133
  positive, neutral tokens otherwise, destructive for an error only. The ring
129
134
  is an inset shadow so it costs no layout, exactly as \`ring-inset\` does. */
130
135
  .pill { display: inline-flex; align-items: center; flex: none; white-space: nowrap;
131
136
  font-size: 11px; font-weight: 500; letter-spacing: 0.02em;
132
137
  padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border); }
133
- .pill.positive { background: var(--gold-fill); color: var(--accent-gold);
134
- box-shadow: inset 0 0 0 1px var(--gold-ring); }
138
+ .pill.positive { background: var(--brand-fill); color: var(--accent-brand);
139
+ box-shadow: inset 0 0 0 1px var(--brand-ring); }
135
140
  .pill.neutral { background: var(--muted); color: var(--muted-foreground); }
136
141
  .pill.quiet { background: transparent; color: var(--muted-foreground); opacity: 0.7; }
137
142
  .pill.negative { background: var(--destructive-fill); color: var(--destructive);
138
143
  box-shadow: inset 0 0 0 1px var(--destructive-ring); }
139
144
 
140
145
  /* Ghost is the variant every button on these pages is: muted until it is
141
- wanted, and never gold, because gold is reserved for saying a thing is good. */
146
+ wanted, and never the accent, which is reserved for saying a thing is good. */
142
147
  .btn { font: inherit; font-size: 12px; font-weight: 500; line-height: 1;
143
148
  padding: 6px 9px; border-radius: 6px; cursor: pointer;
144
149
  background: transparent; color: var(--muted-foreground);
@@ -16,11 +16,12 @@
16
16
  * hardcoded `#0d1117` that read as a black rectangle on a light machine. The
17
17
  * system names a background per mode, so the fix is now to paint the right
18
18
  * one. `color-scheme` stays, for form controls and scrollbars.
19
- * - **The success mark is gold, not green.** It was lucide's `check` at
20
- * `#059669`, chosen to match the tick the Lanes app puts against a connected
21
- * integration. The design system is explicit that gold is the only accent and
22
- * "gold for positive, neutral tokens otherwise", so the green was the odd one
23
- * out rather than the match it was meant to be.
19
+ * - **The success mark carries the accent.** It is lucide's `check` at
20
+ * `--accent-brand`, which matches the tick the Lanes app puts against a
21
+ * connected integration. This went the long way round: the mark was `#059669`,
22
+ * was changed to gold because the design system allowed one accent and gold
23
+ * was it, and is emerald again now that emerald is that one accent. The rule
24
+ * never moved, only the colour it points at.
24
25
  *
25
26
  * What has not changed is that colour is reserved for status and spent nowhere
26
27
  * else — not on emphasis, and not on the heading.
@@ -49,14 +50,14 @@ body { display: flex; align-items: center; justify-content: center;
49
50
  .label { margin: 0 0 6px; font-size: 15px; font-weight: 500; color: var(--muted-foreground); }
50
51
  h1 { margin: 0 0 18px; }
51
52
  .detail { margin: 0; font-size: 15px; line-height: 1.6; color: var(--muted-foreground); }
52
- .icon { display: block; margin: 0 auto 18px; color: var(--accent-gold); }
53
+ .icon { display: block; margin: 0 auto 18px; color: var(--accent-brand); }
53
54
  .err h1 { color: var(--destructive); }
54
55
  `.trim();
55
56
 
56
57
  /**
57
58
  * The success mark, above the label.
58
59
  *
59
- * lucide's `check`, in `--accent-gold` which is what "gold for positive"
60
+ * lucide's `check`, in `--accent-brand` - which is what "accent for positive"
60
61
  * means when the positive thing is a connection that now works. It carries no
61
62
  * colour of its own; `.icon` sets it, so it follows the token if the token moves.
62
63
  *
@@ -1,4 +1,11 @@
1
- import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
1
+ import {
2
+ CONNECTIONS_FILE,
3
+ listProfiles,
4
+ SUPPORTED_CONTRACT,
5
+ WORKSPACE_FILE,
6
+ workspaceFiles,
7
+ writeWorkspaceFile,
8
+ } from '#profile';
2
9
  import { newConnectionsTemplate } from './config-templates.ts';
3
10
  import { ConfigDocument } from './config-edit.ts';
4
11
  import { ok, print, style, warn } from './output.ts';
@@ -14,6 +21,56 @@ import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './con
14
21
  * what to say when a profile will not open.
15
22
  */
16
23
 
24
+ /**
25
+ * Stamp the registry with the contract the workspace is actually in.
26
+ *
27
+ * `renameRegistry` copies `lanes-link.yaml` to `workspaces.yaml` byte for byte,
28
+ * which is what makes an interruption survivable — but it carried the old
29
+ * `contract:` across with everything else, so a migrated workspace sat at 3
30
+ * while every profile in it said 4, and a workspace `profile add` created said
31
+ * 4 from the start. Two workspaces at the same contract disagreeing about which
32
+ * one they are in.
33
+ *
34
+ * Cosmetic in most commands, which read the profiles. Not in `isUnmigrated`
35
+ * (`src/profile/registry.ts`), the one place a registry's own contract is read:
36
+ * it compares against `SUPPORTED_CONTRACT` to tell a pointer at an out-of-date
37
+ * bucket from a pointer at the wrong target, so a stale stamp there answers a
38
+ * question about contract 4 with a refusal naming *contract 1* and sends the
39
+ * operator to a `deploy` that changes nothing.
40
+ *
41
+ * Called from the migration, so its output needs no repair, and from the sweep,
42
+ * for the workspaces 0.9.0 already migrated. One spelling, for the reason the
43
+ * template and `ensureOwnerLayer` share one: two would have to agree forever.
44
+ */
45
+ export async function ensureRegistryContract(workspaceRoot: string): Promise<boolean> {
46
+ const files = workspaceFiles(workspaceRoot);
47
+ if (!(await files.has(WORKSPACE_FILE))) return false;
48
+
49
+ const document = await ConfigDocument.openKey(workspaceRoot, WORKSPACE_FILE);
50
+ if (document.getIn(['contract']) === SUPPORTED_CONTRACT) return false;
51
+
52
+ document.setIn(['contract'], SUPPORTED_CONTRACT);
53
+ await document.save();
54
+ return true;
55
+ }
56
+
57
+ /**
58
+ * The first line of an error that actually says something.
59
+ *
60
+ * `message.split('\n')[0]` was the whole of this, and a `ConfigError` from a
61
+ * schema failure is `<path>:\n <field>: <reason>` — so the warning rendered as
62
+ * "could not give personal its owner layer: /…/personal.yaml:" and named no
63
+ * reason at all. Seen for real on an upgrade, twice, with nothing after the
64
+ * colon.
65
+ */
66
+ function reasonOf(error: unknown): string {
67
+ if (!(error instanceof Error)) return String(error);
68
+
69
+ const lines = error.message.split('\n').map((line) => line.trim());
70
+ const said = lines.find((line) => line !== '' && !line.endsWith(':'));
71
+ return said ?? lines.find((line) => line !== '') ?? error.message;
72
+ }
73
+
17
74
  /** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
18
75
  function listSurfaces(): string {
19
76
  const names = [...DEFAULT_SURFACES];
@@ -109,11 +166,18 @@ export async function repairOwnerLayer(
109
166
  } catch (error) {
110
167
  say(
111
168
  warn(
112
- `could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
169
+ `could not give ${name} its owner layer: ${reasonOf(error)}`,
113
170
  ),
114
171
  );
115
172
  }
116
173
  }
117
174
 
118
175
  if (connectionsChanged) await connections.save();
176
+
177
+ // After the profiles, so a workspace that could not be repaired is not told
178
+ // it is current. The stamp says what the workspace is; the profiles are what
179
+ // makes it true.
180
+ if (await ensureRegistryContract(workspaceRoot)) {
181
+ say(ok(`stamped ${style.bold(WORKSPACE_FILE)} as contract ${SUPPORTED_CONTRACT}`));
182
+ }
119
183
  }
@@ -14,6 +14,7 @@ import { ConfigDocument } from './config-edit.ts';
14
14
  import { C3 } from './contract3-layout.ts';
15
15
  import { grantingProfiles, planMoves, type DataPlan, type Renames } from './contract4-data.ts';
16
16
  import { planRenames } from './contract4-rename.ts';
17
+ import { ensureRegistryContract } from './config-repair-sweep.ts';
17
18
  import {
18
19
  assertConnectionsSavable,
19
20
  readConnectionRows,
@@ -153,6 +154,12 @@ export async function migrateToContract4(
153
154
  }
154
155
 
155
156
  await renameRegistry(workspaceRoot);
157
+
158
+ // The registry's own stamp, which the byte-for-byte rename carries across
159
+ // unchanged. Here rather than inside `renameRegistry` because that function
160
+ // returns early on a workspace already holding `workspaces.yaml`, which is
161
+ // exactly the workspace whose stamp is stale.
162
+ await ensureRegistryContract(workspaceRoot);
156
163
  await applyMoves(files, plan.moves);
157
164
 
158
165
  // Credentials before the rows: a ref is derived from the id, so the rows must