@lanes-sh/link 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -10,6 +10,7 @@ import {
10
10
  toPolicyDocument,
11
11
  } from '#registry';
12
12
  import { announce, emit, fail, ok, print, warn } from '../../output.ts';
13
+ import { staleNudge } from '../../release.ts';
13
14
  import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
14
15
  import { openRuntime, resolveProfile, type GlobalFlags } from '../../runtime.ts';
15
16
 
@@ -189,6 +190,12 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
189
190
  });
190
191
  }
191
192
 
193
+ // Every finding above is about this profile; this one is about the binary
194
+ // reading it. Silent when the registry cannot be reached — `doctor` is
195
+ // expected to work on a plane, and "could not check" is not a finding.
196
+ const stale = await staleNudge();
197
+ if (stale !== null) warnings.push({ kind: 'stale_release', message: stale });
198
+
192
199
  await reportCapabilityDrift(runtime, (message) =>
193
200
  warnings.push({ kind: 'capability_drift', message }),
194
201
  );
@@ -1,6 +1,7 @@
1
1
  import { startEndpoint } from '#server/endpoint.ts';
2
2
  import { streamLogger } from '#server/logging.ts';
3
3
  import { announce, ok, print, style, warn } from '../../output.ts';
4
+ import { staleNudge } from '../../release.ts';
4
5
  import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
5
6
 
6
7
  /** `lanes link start` — reconcile, then serve every profile on one endpoint. */
@@ -41,6 +42,12 @@ export async function start(
41
42
 
42
43
  print(ok(`serving ${style.bold(endpoint.url)}`));
43
44
  print(style.dim(` profiles: ${endpoint.profiles.join(', ')}`));
45
+
46
+ // Last, not first: the endpoint is what someone ran this for, and a version
47
+ // note in front of it would be the first thing they read and the least useful.
48
+ const stale = await staleNudge();
49
+ if (stale !== null) print(warn(stale));
50
+
44
51
  print(style.dim('Ctrl-C to stop.'));
45
52
 
46
53
  const shutdown = async (): Promise<void> => {
@@ -0,0 +1,255 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, sep } from 'node:path';
3
+ import { installRoot } from '#profile';
4
+ import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
5
+ import { PACKAGE, release, type ReleaseState } from '../release.ts';
6
+ import { version } from '../version.ts';
7
+
8
+ /**
9
+ * `lanes link update` — install the newer release, or say why it will not.
10
+ *
11
+ * There is no build step and no compiled artifact, so updating means exactly
12
+ * one thing: replace the installed package directory with a newer tarball from
13
+ * the registry. `bin/lanes` resolves its own symlink chain and execs Bun on the
14
+ * `src/` inside that directory, so the shipped source *is* the running code and
15
+ * the symlink on the PATH never has to move.
16
+ *
17
+ * Bun is the only installer this drives. `bun install -g @lanes-sh/link` is the
18
+ * only install documented, `engines.bun` requires it, and the shim refuses to
19
+ * run without it — so inferring a package manager would be machinery serving a
20
+ * case nobody is told to create. The case that does exist is handled below
21
+ * rather than ignored: an `npm i -g` install updated with Bun gets a second
22
+ * copy somewhere else on the PATH, which this detects and reports instead of
23
+ * doing quietly.
24
+ *
25
+ * Nothing here is control plane — it touches the install, not a profile — so it
26
+ * resolves no profile and no target, and is the second command after `version`
27
+ * that prints no `announce` line.
28
+ */
29
+
30
+ export interface UpdateFlags {
31
+ /** Report and exit without installing anything. */
32
+ readonly check?: boolean | undefined;
33
+ readonly json?: boolean | undefined;
34
+ }
35
+
36
+ /**
37
+ * What `update` would do, and why.
38
+ *
39
+ * `'checkout'` is a refusal: `bun link` puts a checkout on the same PATH entry
40
+ * a published install would occupy, so installing from the registry there would
41
+ * leave two copies of this CLI and no indication of which one answers. `git
42
+ * pull` is the update in a checkout, and saying so is more useful than doing
43
+ * something surprising.
44
+ */
45
+ export type UpdateAction = 'install' | 'current' | 'ahead' | 'checkout' | 'unknown';
46
+
47
+ export interface UpdateDecision {
48
+ readonly action: UpdateAction;
49
+ /** The argv to run, or `null` when nothing should be run. */
50
+ readonly install: readonly string[] | null;
51
+ readonly message: string;
52
+ /** Something true and unwelcome about this install, if there is anything. */
53
+ readonly warning: string | null;
54
+ }
55
+
56
+ export interface UpdateInput {
57
+ readonly installed: string;
58
+ readonly latest: string | null;
59
+ readonly state: ReleaseState;
60
+ /** Where this CLI is installed — `installRoot()`, not the workspace. */
61
+ readonly root: string;
62
+ /** Where Bun keeps global installs, so a copy landing elsewhere is visible. */
63
+ readonly bunGlobal: string;
64
+ }
65
+
66
+ /**
67
+ * The whole decision, as a function of five strings.
68
+ *
69
+ * Split from the spawn because the alternative is a command whose only test is
70
+ * one that replaces the copy of this CLI on the machine running the suite. Every
71
+ * branch below is reachable from `update.test.ts` with no network and no
72
+ * subprocess — including the stale branch, which the checkout this is written in
73
+ * can never reach on its own, being by definition the newest thing there is.
74
+ */
75
+ export function updatePlan(input: UpdateInput): UpdateDecision {
76
+ const { installed, latest, state, root, bunGlobal } = input;
77
+
78
+ // A published install lives under `node_modules`; a checkout does not. Cheaper
79
+ // and steadier than looking for `.git`, which a tarball could carry and a
80
+ // shallow export could lack.
81
+ const published = root.split(sep).includes('node_modules');
82
+
83
+ if (!published) {
84
+ return {
85
+ action: 'checkout',
86
+ install: null,
87
+ message: `${root} is a checkout, not an install — git pull is the update here`,
88
+ warning: null,
89
+ };
90
+ }
91
+
92
+ if (state === 'unknown') {
93
+ return {
94
+ action: 'unknown',
95
+ install: null,
96
+ message:
97
+ latest === null
98
+ ? `could not reach the registry — ${installed} is installed`
99
+ : `cannot compare ${installed} against ${latest}`,
100
+ warning: null,
101
+ };
102
+ }
103
+
104
+ if (state === 'ahead') {
105
+ return {
106
+ action: 'ahead',
107
+ install: null,
108
+ message: `${installed} is installed, ahead of the published ${latest ?? 'release'}`,
109
+ warning: null,
110
+ };
111
+ }
112
+
113
+ if (state === 'current') {
114
+ return { action: 'current', install: null, message: `${installed} is current`, warning: null };
115
+ }
116
+
117
+ // Installed by npm, updated by Bun: `bun install -g` writes into its own
118
+ // global prefix and leaves the npm copy where it is, so both are on the PATH
119
+ // and its order decides which one answers. Worth saying before, not after.
120
+ const elsewhere = !root.startsWith(bunGlobal + sep);
121
+
122
+ return {
123
+ action: 'install',
124
+ install: ['install', '-g', PACKAGE],
125
+ message: `${installed} installed, ${latest} available`,
126
+ warning: elsewhere
127
+ ? `this copy is at ${root}, which is not under ${bunGlobal} — ` +
128
+ 'Bun will install a second copy there rather than replace this one, ' +
129
+ 'and your PATH order decides which one answers'
130
+ : null,
131
+ };
132
+ }
133
+
134
+ /** Where Bun keeps global installs, honouring `BUN_INSTALL`. */
135
+ export function bunGlobalRoot(env: Record<string, string | undefined> = process.env): string {
136
+ return join(env['BUN_INSTALL'] ?? join(homedir(), '.bun'), 'install', 'global');
137
+ }
138
+
139
+ export async function update(flags: UpdateFlags): Promise<void> {
140
+ const current = await release();
141
+ const root = installRoot(import.meta.dir);
142
+ const decision = updatePlan({
143
+ installed: current.installed,
144
+ latest: current.latest,
145
+ state: current.state,
146
+ root,
147
+ bunGlobal: bunGlobalRoot(),
148
+ });
149
+
150
+ // A gate wants a non-zero exit for the one state that needs action. An
151
+ // unreachable registry is not that state — failing a build because a network
152
+ // was down would make this the flakiest check in it.
153
+ if (flags.check === true && decision.action === 'install') process.exitCode = 1;
154
+
155
+ const report = {
156
+ installed: current.installed,
157
+ latest: current.latest,
158
+ state: current.state,
159
+ action: decision.action,
160
+ root,
161
+ ...(decision.install !== null ? { install: `bun ${decision.install.join(' ')}` } : {}),
162
+ ...(decision.warning !== null ? { warning: decision.warning } : {}),
163
+ };
164
+
165
+ if (flags.check === true || decision.action !== 'install') {
166
+ return emit(flags.json, report, () => {
167
+ if (decision.action === 'install') {
168
+ print(warn(decision.message));
169
+ if (decision.warning !== null) print(style.dim(` ${decision.warning}`));
170
+ print(style.dim(' run: lanes link update'));
171
+ return;
172
+ }
173
+
174
+ // Green for the two states that need nothing. A refusal and an
175
+ // unreachable registry are neither wrong nor fine, and `ok` would claim
176
+ // the second of those.
177
+ if (decision.action === 'current' || decision.action === 'ahead') {
178
+ print(ok(decision.message));
179
+ return;
180
+ }
181
+
182
+ print(style.dim(decision.message));
183
+ });
184
+ }
185
+
186
+ // Stderr, both of them. What this command produces is the version change, and
187
+ // with `--json` that is a document — a line of prose in front of it corrupts
188
+ // it for whatever is parsing, which is the whole reason `emit` exists.
189
+ if (decision.warning !== null) progress(warn(decision.warning));
190
+ progress(style.dim(`bun ${decision.install!.join(' ')}`));
191
+
192
+ const installed = await runInstall(decision.install!, flags.json === true);
193
+ if (!installed) {
194
+ printErr(fail('the install did not complete — nothing was changed'));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+
199
+ // Read the version back off disk rather than trusting the exit code. `version()`
200
+ // reads `package.json` from the install root at call time, so this is the one
201
+ // question worth asking after a successful install: did *this* copy change?
202
+ // Unchanged after a clean install is the second-copy case above, seen from the
203
+ // other side.
204
+ const landed = version();
205
+
206
+ return emit(
207
+ flags.json,
208
+ {
209
+ ...report,
210
+ // The action was `install`; this is what came of it. Inventing a third
211
+ // action value would describe an outcome as a decision.
212
+ result: landed === current.installed ? 'unchanged' : 'installed',
213
+ installed: landed,
214
+ previous: current.installed,
215
+ },
216
+ () => {
217
+ if (landed === current.installed) {
218
+ print(warn(`bun reported success, but ${root} is still ${landed}`));
219
+ print(style.dim(' the copy it installed is somewhere else on your PATH'));
220
+ print(style.dim(' check with: which -a lanes'));
221
+ return;
222
+ }
223
+
224
+ print(ok(`${current.installed} → ${style.bold(landed)}`));
225
+ print(style.dim(' a running endpoint serves the old code until it is restarted'));
226
+ },
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Hand the install to Bun and let it own the terminal.
232
+ *
233
+ * `process.execPath` rather than `Bun.which('bun')`: this process is already
234
+ * running under the Bun that should do the installing, and a PATH lookup can
235
+ * find a different one — which would resolve the dependency set with a
236
+ * different resolver than the one that will run the result.
237
+ *
238
+ * Output is inherited rather than captured. Bun prints its own progress and its
239
+ * own errors, and paraphrasing a package manager's failure is how a report ends
240
+ * up less useful than the thing it replaced. Its stdout is dropped under
241
+ * `--json` for the same reason the lines above go to stderr: the document on
242
+ * stdout has to be the only thing on stdout. Its stderr is kept either way,
243
+ * because a failure is worth reading in both modes.
244
+ */
245
+ async function runInstall(argv: readonly string[], json: boolean): Promise<boolean> {
246
+ try {
247
+ const child = Bun.spawn([process.execPath, ...argv], {
248
+ stdout: json ? 'ignore' : 'inherit',
249
+ stderr: 'inherit',
250
+ });
251
+ return (await child.exited) === 0;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
package/src/cli/main.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  vaultRemove,
39
39
  vaultSet,
40
40
  } from './commands/owner.ts';
41
+ import { update } from './commands/update.ts';
41
42
  import { globalFlags, ownerFlags, parseArgv, text } from './argv.ts';
42
43
  import { PROGRAM, USAGE } from './usage.ts';
43
44
  import { version } from './version.ts';
@@ -335,6 +336,9 @@ export async function run(argv: readonly string[]): Promise<void> {
335
336
  print(version());
336
337
  return;
337
338
 
339
+ case 'update':
340
+ return update({ check: flags['check'] === true, json });
341
+
338
342
  default:
339
343
  throw new Error(`Unknown command "${first}". Run: ${PROGRAM} help`);
340
344
  }
@@ -0,0 +1,111 @@
1
+ import { version } from './version.ts';
2
+
3
+ /**
4
+ * Whether a newer release than this one has been published.
5
+ *
6
+ * `version.ts` answers which release is installed, which stopped being the
7
+ * whole question when this started shipping from npm: two machines can now sit
8
+ * a release apart with nothing on either to say so, and the only upgrade
9
+ * affordance in the tree was a `contract` mismatch telling someone to "Upgrade
10
+ * lanes-link" without naming a command.
11
+ *
12
+ * Every function here degrades to `null` or `'unknown'` rather than throwing. A
13
+ * version check is never the reason a command fails — `doctor`, `start`, and
14
+ * `deploy` each print one line from this and must all work on a plane.
15
+ */
16
+
17
+ /** The npm package this CLI ships as, and the only thing `update` will install. */
18
+ export const PACKAGE = '@lanes-sh/link';
19
+
20
+ /**
21
+ * The dist-tags document, not the packument.
22
+ *
23
+ * `registry.npmjs.org/<name>` carries every version ever published with its
24
+ * full manifest — hundreds of kilobytes to answer a question whose answer is
25
+ * eighteen bytes. This endpoint returns `{"latest":"0.2.0"}` and nothing else.
26
+ */
27
+ const DIST_TAGS = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE)}/dist-tags`;
28
+
29
+ /**
30
+ * The same budget `endpointHealth` gives its probe.
31
+ *
32
+ * Long enough for a warm connection, short enough that a command which only
33
+ * mentions staleness in passing does not appear to hang on a captive-portal
34
+ * network that accepts the connection and then says nothing.
35
+ */
36
+ const PROBE_TIMEOUT_MS = 700;
37
+
38
+ export type ReleaseState = 'current' | 'stale' | 'ahead' | 'unknown';
39
+
40
+ export interface Release {
41
+ readonly installed: string;
42
+ /** `null` when the registry could not be reached, or answered something else. */
43
+ readonly latest: string | null;
44
+ readonly state: ReleaseState;
45
+ }
46
+
47
+ /**
48
+ * How the installed version stands against the published one.
49
+ *
50
+ * `'ahead'` is not a mistake: a contributor running from a checkout is usually
51
+ * a version ahead of the registry, and telling them they are behind would be
52
+ * both wrong and the thing they see most often.
53
+ *
54
+ * Pure, and separate from the fetch, so every branch is testable without a
55
+ * network — which is the only way the stale path gets covered at all, given the
56
+ * checkout this is written in is by definition current.
57
+ */
58
+ export function releaseState(installed: string, latest: string | null): ReleaseState {
59
+ if (latest === null) return 'unknown';
60
+
61
+ try {
62
+ const order = Bun.semver.order(installed, latest);
63
+ return order === 0 ? 'current' : order < 0 ? 'stale' : 'ahead';
64
+ } catch {
65
+ // `Bun.semver.order` throws on anything it cannot parse rather than
66
+ // ordering it arbitrarily. A registry that answers with something other
67
+ // than a version, or a hand-edited `package.json`, is an unknown state and
68
+ // not a reason to claim either answer.
69
+ return 'unknown';
70
+ }
71
+ }
72
+
73
+ /** What the registry calls `latest`, or `null` if it did not say. */
74
+ export async function latestRelease(): Promise<string | null> {
75
+ try {
76
+ const response = await fetch(DIST_TAGS, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
77
+ if (!response.ok) return null;
78
+
79
+ const body = (await response.json()) as { latest?: unknown };
80
+ return typeof body.latest === 'string' ? body.latest : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** The installed version, the published one, and how they stand. */
87
+ export async function release(): Promise<Release> {
88
+ const installed = version();
89
+ const latest = await latestRelease();
90
+
91
+ return { installed, latest, state: releaseState(installed, latest) };
92
+ }
93
+
94
+ /**
95
+ * The one line `doctor`, `start`, and `deploy` print when this install is behind.
96
+ *
97
+ * One string in one place, because three commands saying it three ways is how
98
+ * two of them end up naming a command that has been renamed. `null` for every
99
+ * state but `'stale'`: nothing is worth saying about an install that is current,
100
+ * and an unreachable registry is not news.
101
+ */
102
+ export function staleLine(current: Release): string | null {
103
+ if (current.state !== 'stale') return null;
104
+
105
+ return `${current.installed} is installed, ${current.latest} is out — run: lanes link update`;
106
+ }
107
+
108
+ /** `staleLine` over a fresh probe, for a caller that has no `Release` in hand. */
109
+ export async function staleNudge(): Promise<string | null> {
110
+ return staleLine(await release());
111
+ }
package/src/cli/usage.ts CHANGED
@@ -90,6 +90,7 @@ ${style.bold('Inspection')}
90
90
  ${PROGRAM} audit verify has anything in the log been altered or removed
91
91
  ${PROGRAM} config show
92
92
  ${PROGRAM} version which release this is — same as lanes --version
93
+ ${PROGRAM} update [--check] [--json] install the newer release, or say what is available
93
94
 
94
95
  ${style.bold('Attachments')}
95
96
  ${PROGRAM} attach <file> --connection <provider>.<account>
@@ -1,5 +1,6 @@
1
1
  import { ConfigError, resolveDeployTarget, type DeployConfig } from '#profile';
2
2
  import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
3
+ import { staleNudge } from '#cli/release.ts';
3
4
  import { confirm, isInteractive } from '#cli/prompt.ts';
4
5
  import { openSecretStoreFor, resolveProfile, type GlobalFlags } from '#cli/runtime.ts';
5
6
  import { resolveTarget, vaultEnv } from './bootstrap.ts';
@@ -57,6 +58,12 @@ export async function deploy(flags: DeployFlags): Promise<void> {
57
58
  // rejected on boot should be rejected here, not after a five-minute build.
58
59
  print(ok(`${resolution.profilePath} is valid`));
59
60
 
61
+ // The CLI planning this rollout, not the image it will build. An old one
62
+ // plans an old rollout, and a build is the most expensive place to find that
63
+ // out.
64
+ const stale = await staleNudge();
65
+ if (stale !== null) print(warn(stale));
66
+
60
67
  const declared = await resolveTarget({
61
68
  config,
62
69
  profilePath: resolution.profilePath,