@bridge4dev/runner 0.26.0 → 0.27.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/dist/index.js CHANGED
@@ -10,7 +10,8 @@ import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
11
  import { loadConfig, requireConfig, saveConfig } from './config.js';
12
12
  import { log } from './log.js';
13
- import { installIsWritable, isSupervisedProcess, resolveInstalledPackageDir, } from './self-update.js';
13
+ import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
14
+ import { applyStoredClaudeToken } from './agent-auth.js';
14
15
  import { Supervisor } from './supervisor.js';
15
16
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
16
17
  import { RunnerWsClient } from './ws-client.js';
@@ -103,8 +104,13 @@ function hasExecutable(name) {
103
104
  * older runner drops a frame it cannot parse without ever replying, so a
104
105
  * command it does not know would just hang until the gateway timeout.
105
106
  */
106
- function runnerCapabilities() {
107
+ function runnerCapabilities(apiUrlOverride) {
107
108
  const config = loadConfig();
109
+ // At PAIR time there is no config yet, so the API this runner is about to
110
+ // belong to has to be passed in — otherwise the very first `hello` (the one
111
+ // that creates the server record) would omit the update command, and the
112
+ // dashboard would show a stale one until the daemon reconnected.
113
+ const apiUrl = apiUrlOverride ?? config?.api.url;
108
114
  const localLimit = config?.limits?.max_sessions;
109
115
  // Session 14: the machine owner's veto. Default on — the safety of the
110
116
  // feature is that a DevBridge manager approves every command first — but the
@@ -135,6 +141,37 @@ function runnerCapabilities() {
135
141
  * is only half the instruction without a name to restart under.
136
142
  */
137
143
  runnerUser: runnerIdentity().user,
144
+ /**
145
+ * Which agent CLIs are actually on this user's PATH (0.27.0).
146
+ *
147
+ * Different question from `agents` above, and the difference cost a
148
+ * support round: `agents` says which agents can RUN sessions (Claude
149
+ * always can — the SDK bundles its own binary), while signing in needs the
150
+ * standalone CLI to exist for this user. On a dedicated-user install it
151
+ * routinely does not, and the sign-in button then failed with an error
152
+ * about `script`, about a machine whose real problem was that nobody had
153
+ * installed `claude` for that user at all.
154
+ */
155
+ agentClis: {
156
+ claude: hasExecutable('claude'),
157
+ codex: hasExecutable('codex'),
158
+ },
159
+ /**
160
+ * Where npm put this package, and the command that updates it here
161
+ * (0.27.0).
162
+ *
163
+ * The dashboard used to assemble the update command itself and then patch
164
+ * it with string surgery for the dedicated-user case. It cannot: only this
165
+ * process knows the prefix it was installed into, and `npm install -g`
166
+ * without that prefix is precisely the EACCES the owner pasted. So the
167
+ * machine states its own command and the panel just shows it.
168
+ */
169
+ ...(installPrefixFor() ? { npmPrefix: installPrefixFor() } : {}),
170
+ ...(apiUrl
171
+ ? {
172
+ updateCommand: manualUpdateCommand(`${apiUrl.replace(/\/$/, '')}/api/v1/dev-setup/runner.tgz`),
173
+ }
174
+ : {}),
138
175
  /**
139
176
  * A stricter ceiling set on the machine itself (layer 1). Reported so the
140
177
  * dashboard can explain why raising the number there changed nothing.
@@ -306,7 +343,7 @@ async function cmdPair(args) {
306
343
  name,
307
344
  runnerVersion: RUNNER_VERSION,
308
345
  osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
309
- capabilities: runnerCapabilities(),
346
+ capabilities: runnerCapabilities(apiUrl),
310
347
  }),
311
348
  });
312
349
  const body = (await response.json().catch(() => null));
@@ -429,6 +466,12 @@ async function cmdDaemon() {
429
466
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
430
467
  sweepOrphanedMcpConfigs();
431
468
  await repairResourceLimits();
469
+ // A Claude token this runner captured through the sign-in relay. Applied
470
+ // BEFORE any adapter exists, because `scrubbedEnv()` copies it out of this
471
+ // process's environment for every session it starts.
472
+ if (applyStoredClaudeToken()) {
473
+ log.info('daemon: using the Claude token stored on this server');
474
+ }
432
475
  const agents = installedAgents();
433
476
  const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
434
477
  const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
@@ -654,8 +697,16 @@ async function runnerChecks() {
654
697
  const state = await systemctlProperty('ActiveState');
655
698
  const enabled = await systemctlProperty('UnitFileState');
656
699
  const running = state === 'active';
700
+ // Three separate promises, and the verdict has to fail on any of them.
701
+ // Until 0.27.0 only the first counted: a service that was running but
702
+ // would die at the next logout (linger off) or never come back after a
703
+ // reboot (not enabled) still printed ✔ and still said READY, with the
704
+ // `loginctl enable-linger` line sitting UNDER the tick as if it were
705
+ // advice. The whole point of an acceptance sheet is that a green one means
706
+ // walk away — so a runner that stops when its user logs out is a red line.
707
+ const durable = running && enabled === 'enabled' && linger !== false;
657
708
  checks.push({
658
- ok: running,
709
+ ok: durable,
659
710
  name: 'service',
660
711
  detail: running
661
712
  ? `running${enabled === 'enabled' ? ', starts on boot' : ' — but NOT enabled: it will not come back after a reboot'}` +
@@ -663,7 +714,7 @@ async function runnerChecks() {
663
714
  ? ', survives logout'
664
715
  : linger === false
665
716
  ? ', but linger is OFF: it stops when this user logs out'
666
- : '')
717
+ : ', linger state unknown')
667
718
  : `NOT running (${state ?? 'unknown'})`,
668
719
  ...(running
669
720
  ? enabled !== 'enabled'
@@ -725,10 +776,27 @@ async function runnerChecks() {
725
776
  ...(canUpdate || packageDir === null
726
777
  ? {}
727
778
  : {
728
- fix: `sudo -iu ${me.user} npm install -g --ignore-scripts --prefix ~/.local ${'@bridge4dev/runner'}`,
729
- fixMore: `(reinstalling as ${me.user} is what makes the button work; until then every update is a root command)`,
779
+ // NOT `--prefix ~/.local`, which is what this line used to say. The
780
+ // tilde is expanded by the CALLING shell, so run as root it aimed at
781
+ // /root/.local — a directory the daemon's user cannot write — and
782
+ // the install died naming a home nobody had chosen. `sudo -iu` hands
783
+ // the string to the TARGET user's login shell, so `$HOME` is theirs.
784
+ fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --loglevel=error @bridge4dev/runner'`,
785
+ fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
730
786
  }),
731
787
  });
788
+ // The relay that signs an agent in runs the CLI on a pty, and util-linux
789
+ // `script` is what allocates it. Missing on minimal images, and its absence
790
+ // used to surface as «claude login exited before printing a sign-in URL».
791
+ const hasScript = hasExecutable('script');
792
+ checks.push({
793
+ ok: hasScript,
794
+ name: 'sign-in relay',
795
+ detail: hasScript
796
+ ? 'ready (util-linux `script` present)'
797
+ : '`script` (util-linux) is missing — the dashboard sign-in button cannot run',
798
+ ...(hasScript ? {} : { fix: 'apt-get install -y bsdextrautils util-linux' }),
799
+ });
732
800
  return checks;
733
801
  }
734
802
  async function agentChecks() {
@@ -744,12 +812,45 @@ async function agentChecks() {
744
812
  process.env['DEVBRIDGE_RUNNER_LOG'] = previousLogLevel;
745
813
  });
746
814
  const checks = [];
815
+ // Does the CLI exist for THIS user, before asking whether it is signed in?
816
+ // «not signed in» over a machine that has no `claude` at all sends the
817
+ // reader to a login screen for a command that does not exist — and doctor's
818
+ // own remedy (`sudo -iu <user> claude`) was then `command not found`. That
819
+ // is the state a dedicated-user install leaves behind by default.
820
+ const cliPresent = {
821
+ claude: hasExecutable('claude'),
822
+ codex: hasExecutable('codex'),
823
+ };
824
+ checks.push({
825
+ ok: cliPresent.claude,
826
+ name: 'claude cli',
827
+ detail: cliPresent.claude
828
+ ? `on ${me.user}'s PATH`
829
+ : `not installed for ${me.user} — sessions still run (the SDK bundles its own), but signing in needs the CLI`,
830
+ ...(cliPresent.claude
831
+ ? {}
832
+ : {
833
+ fix: `sudo -iu ${me.user} sh -lc 'curl -fsSL https://claude.ai/install.sh | bash'`,
834
+ }),
835
+ });
836
+ // Codex is optional: plenty of machines only ever run Claude sessions, and a
837
+ // red line for an agent nobody uses is noise that trains people to ignore
838
+ // the sheet. Reported, not judged.
839
+ checks.push({
840
+ ok: true,
841
+ name: 'codex cli',
842
+ detail: cliPresent.codex ? `on ${me.user}'s PATH` : `not installed for ${me.user} (optional)`,
843
+ ...(cliPresent.codex
844
+ ? {}
845
+ : {
846
+ fix: `sudo -iu ${me.user} sh -lc 'npm install -g @openai/codex' # only if you use Codex`,
847
+ }),
848
+ });
747
849
  for (const [agent, info] of [
748
850
  ['claude', auth.claude],
749
851
  ['codex', auth.codex],
750
852
  ]) {
751
853
  const signedIn = info.status === 'ok';
752
- const command = agent === 'claude' ? 'claude' : 'codex login';
753
854
  checks.push({
754
855
  ok: signedIn,
755
856
  name: `${agent} login`,
@@ -758,12 +859,39 @@ async function agentChecks() {
758
859
  ...(signedIn
759
860
  ? {}
760
861
  : {
761
- fix: `${me.isRoot ? command : `sudo -iu ${me.user} ${command}`}${agent === 'claude' ? ' then /login' : ''}`,
862
+ // The dashboard button FIRST. It is the product's own path, it
863
+ // needs no shell on the server, and an installing agent cannot
864
+ // perform an interactive OAuth login anyway — so telling it to
865
+ // «run claude and do /login» is telling it to stop. That is
866
+ // exactly where the last three installs stopped.
867
+ fix: `Dashboard → Development → this server → AGENTS → «Sign in» next to ${agent === 'claude' ? 'Claude Code' : 'Codex'}`,
868
+ fixMore: cliPresent[agent]
869
+ ? `or on the server: ${me.isRoot ? '' : `sudo -iu ${me.user} `}${agent === 'claude' ? 'claude auth login' : 'codex login'}`
870
+ : `(install the CLI first — see the «${agent} cli» line above)`,
762
871
  }),
763
872
  });
764
873
  }
765
874
  const contour = agentConfigContour(me.home);
766
875
  const elsewhere = otherHomeWithAgents(me);
876
+ // MCP servers a session will really see. Only user scope survives a session
877
+ // worktree, and «configured but in the wrong scope» looks identical to
878
+ // «working» from anywhere except inside a session.
879
+ const mcpHidden = contour.mcpUserScope === 0 && contour.mcpProjectScope > 0;
880
+ checks.push({
881
+ ok: !mcpHidden,
882
+ name: 'mcp servers',
883
+ detail: mcpHidden
884
+ ? `${contour.mcpProjectScope} configured, but all per-directory — a session works in its own worktree and will see NONE`
885
+ : contour.mcpUserScope > 0
886
+ ? `${contour.mcpUserScope} available to every session`
887
+ : 'none configured (the DevBridge server is injected per session regardless)',
888
+ ...(mcpHidden
889
+ ? {
890
+ fix: `sudo -iu ${me.user} claude mcp add --scope user <name> … # re-add at user scope`,
891
+ fixMore: '(`claude mcp add` defaults to the current directory’s scope, which no session shares)',
892
+ }
893
+ : {}),
894
+ });
767
895
  checks.push({
768
896
  ok: contour.claudeDir,
769
897
  name: 'claude config',
@@ -851,7 +979,15 @@ async function projectChecks(target, fix) {
851
979
  name: 'docker',
852
980
  detail: docker.problem ?? 'usable by this user',
853
981
  ...(docker.problem
854
- ? { fix: `usermod -aG docker ${me.user} (then restart the runner)` }
982
+ ? {
983
+ fix: `usermod -aG docker ${me.user}`,
984
+ // Restarting the unit is NOT enough and this cost a diagnosis:
985
+ // supplementary groups are fixed when logind creates
986
+ // user@<uid>.service, so the daemon keeps the old set until the
987
+ // whole user manager restarts. `id ${me.user}` then shows the new
988
+ // group while /proc/<pid>/status still shows the old one.
989
+ fixMore: `systemctl restart user@${me.uid}.service # the unit alone keeps the old group set`,
990
+ }
855
991
  : {}),
856
992
  });
857
993
  }
@@ -179,11 +179,11 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
179
179
  id: string;
180
180
  title: string;
181
181
  }[];
182
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
183
182
  mcp?: {
184
183
  url: string;
185
184
  token: string;
186
185
  } | undefined;
186
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
187
187
  model?: string | null | undefined;
188
188
  effort?: string | null | undefined;
189
189
  epoch?: number | undefined;
@@ -381,11 +381,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
381
381
  id: string;
382
382
  title: string;
383
383
  }[];
384
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
385
384
  mcp?: {
386
385
  url: string;
387
386
  token: string;
388
387
  } | undefined;
388
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
389
389
  model?: string | null | undefined;
390
390
  effort?: string | null | undefined;
391
391
  epoch?: number | undefined;
@@ -469,11 +469,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
469
469
  id: string;
470
470
  title: string;
471
471
  }[];
472
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
473
472
  mcp?: {
474
473
  url: string;
475
474
  token: string;
476
475
  } | undefined;
476
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
477
477
  model?: string | null | undefined;
478
478
  effort?: string | null | undefined;
479
479
  epoch?: number | undefined;
@@ -695,11 +695,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
695
695
  id: string;
696
696
  title: string;
697
697
  }[];
698
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
699
698
  mcp?: {
700
699
  url: string;
701
700
  token: string;
702
701
  } | undefined;
702
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
703
703
  model?: string | null | undefined;
704
704
  effort?: string | null | undefined;
705
705
  epoch?: number | undefined;
@@ -778,11 +778,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
778
778
  id: string;
779
779
  title: string;
780
780
  }[];
781
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
782
781
  mcp?: {
783
782
  url: string;
784
783
  token: string;
785
784
  } | undefined;
785
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
786
786
  model?: string | null | undefined;
787
787
  effort?: string | null | undefined;
788
788
  epoch?: number | undefined;
@@ -253,6 +253,7 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
253
253
  shaPath?: string | undefined;
254
254
  } | undefined;
255
255
  }, {
256
+ version?: 1 | undefined;
256
257
  preview?: {
257
258
  run: string;
258
259
  url?: string | undefined;
@@ -260,7 +261,6 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
260
261
  timeoutSec?: number | undefined;
261
262
  stop?: string | undefined;
262
263
  } | undefined;
263
- version?: 1 | undefined;
264
264
  notes?: string | undefined;
265
265
  steps?: {
266
266
  verify?: {
@@ -62,6 +62,28 @@ export interface SelfUpdateOptions {
62
62
  * has one above `packages/runner`.
63
63
  */
64
64
  export declare function resolveInstalledPackageDir(entry?: string): string | null;
65
+ /**
66
+ * The npm prefix the RUNNING package was installed into.
67
+ *
68
+ * This is the whole fix for the dedicated-user layout. `npm install -g` obeys
69
+ * npm's *configured* prefix, and `--prefix` is a per-invocation flag that npm
70
+ * persists nowhere — so the install the instructions prescribed for a dedicated
71
+ * user (`npm install -g --prefix ~/.local …`) produced a runner in
72
+ * `~/.local/lib/node_modules` while every later `npm install -g` aimed at
73
+ * `/usr`. Measured on a live server: `npm prefix -g` → `/usr`,
74
+ * `installIsWritable` → true (both checked directories really are the user's),
75
+ * and then npm dies with EACCES *inside* the update — the exact
76
+ * «the permissions to access this file as the current user» the owner pasted.
77
+ *
78
+ * Deriving the prefix from where we physically are is the truthful answer: it
79
+ * cannot disagree with reality, and it costs no subprocess. Returns null for a
80
+ * layout that is not a global npm install (a source checkout, a vendored copy),
81
+ * where the caller should fall back to `npm prefix -g`.
82
+ *
83
+ * `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>`
84
+ * `<prefix>/lib/node_modules/pkg` → `<prefix>`
85
+ */
86
+ export declare function installPrefixFor(packageDir?: string | null): string | null;
65
87
  /**
66
88
  * Can this user actually replace the installed package?
67
89
  *
@@ -72,8 +94,11 @@ export declare function resolveInstalledPackageDir(entry?: string): string | nul
72
94
  * current user» — a dashboard button that cannot work, and an error in npm's
73
95
  * words rather than ours.
74
96
  *
75
- * Both directories matter: npm rewrites the package AND the `bin` symlink, and
76
- * either one being root-owned is enough to fail.
97
+ * Three directories matter, because npm rewrites all three: the package itself,
98
+ * the `node_modules` above it, and `<prefix>/bin`, where the command symlink
99
+ * lives. `bin` was missing here and is not hypothetical — a prefix whose
100
+ * `lib/node_modules` was handed over with chown while `bin` stayed root-owned
101
+ * passes the first two and still fails.
77
102
  */
78
103
  export declare function installIsWritable(packageDir?: string | null): boolean;
79
104
  /**
@@ -92,5 +117,21 @@ export declare function isSupervisedProcess(env?: NodeJS.ProcessEnv): boolean;
92
117
  * never be talked into fetching its own replacement over http.
93
118
  */
94
119
  export declare function isTrustedTarballUrl(tarballUrl: string, apiUrl: string): boolean;
120
+ /**
121
+ * The command a person can paste on the server to update this exact machine.
122
+ *
123
+ * Built by the runner rather than by the dashboard because only the runner
124
+ * knows the three things that make it correct: which prefix it lives in, which
125
+ * user it runs as, and that user's uid for `XDG_RUNTIME_DIR`. The dashboard
126
+ * used to assemble a generic `npm install -g <url> && systemctl --user restart`
127
+ * and then patch it with string surgery — which produced a command that could
128
+ * not work on precisely the machines that needed it.
129
+ */
130
+ export declare function manualUpdateCommand(tarballUrl: string, options?: {
131
+ prefix?: string | null;
132
+ user?: string;
133
+ uid?: number;
134
+ packageDir?: string | null;
135
+ }): string;
95
136
  export declare function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdateOutcome>;
96
137
  //# sourceMappingURL=self-update.d.ts.map
@@ -55,6 +55,51 @@ export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
55
55
  }
56
56
  return null;
57
57
  }
58
+ /**
59
+ * The npm prefix the RUNNING package was installed into.
60
+ *
61
+ * This is the whole fix for the dedicated-user layout. `npm install -g` obeys
62
+ * npm's *configured* prefix, and `--prefix` is a per-invocation flag that npm
63
+ * persists nowhere — so the install the instructions prescribed for a dedicated
64
+ * user (`npm install -g --prefix ~/.local …`) produced a runner in
65
+ * `~/.local/lib/node_modules` while every later `npm install -g` aimed at
66
+ * `/usr`. Measured on a live server: `npm prefix -g` → `/usr`,
67
+ * `installIsWritable` → true (both checked directories really are the user's),
68
+ * and then npm dies with EACCES *inside* the update — the exact
69
+ * «the permissions to access this file as the current user» the owner pasted.
70
+ *
71
+ * Deriving the prefix from where we physically are is the truthful answer: it
72
+ * cannot disagree with reality, and it costs no subprocess. Returns null for a
73
+ * layout that is not a global npm install (a source checkout, a vendored copy),
74
+ * where the caller should fall back to `npm prefix -g`.
75
+ *
76
+ * `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>`
77
+ * `<prefix>/lib/node_modules/pkg` → `<prefix>`
78
+ */
79
+ export function installPrefixFor(packageDir = resolveInstalledPackageDir()) {
80
+ if (!packageDir)
81
+ return null;
82
+ const marker = `${path.sep}node_modules${path.sep}`;
83
+ const at = packageDir.lastIndexOf(marker);
84
+ if (at < 0)
85
+ return null;
86
+ const nodeModules = packageDir.slice(0, at + marker.length - 1);
87
+ const lib = path.dirname(nodeModules);
88
+ // Anything else is not the layout `npm install -g --prefix` produces, and
89
+ // guessing a prefix that npm would not use is worse than not guessing.
90
+ if (path.basename(lib) !== 'lib')
91
+ return null;
92
+ return path.dirname(lib);
93
+ }
94
+ function isWritable(target) {
95
+ try {
96
+ fs.accessSync(target, fs.constants.W_OK);
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
58
103
  /**
59
104
  * Can this user actually replace the installed package?
60
105
  *
@@ -65,24 +110,29 @@ export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
65
110
  * current user» — a dashboard button that cannot work, and an error in npm's
66
111
  * words rather than ours.
67
112
  *
68
- * Both directories matter: npm rewrites the package AND the `bin` symlink, and
69
- * either one being root-owned is enough to fail.
113
+ * Three directories matter, because npm rewrites all three: the package itself,
114
+ * the `node_modules` above it, and `<prefix>/bin`, where the command symlink
115
+ * lives. `bin` was missing here and is not hypothetical — a prefix whose
116
+ * `lib/node_modules` was handed over with chown while `bin` stayed root-owned
117
+ * passes the first two and still fails.
70
118
  */
71
119
  export function installIsWritable(packageDir = resolveInstalledPackageDir()) {
72
120
  if (!packageDir)
73
121
  return false;
74
- const writable = (target) => {
75
- try {
76
- fs.accessSync(target, fs.constants.W_OK);
77
- return true;
78
- }
79
- catch {
80
- return false;
81
- }
82
- };
122
+ const marker = `${path.sep}node_modules${path.sep}`;
123
+ const at = packageDir.lastIndexOf(marker);
83
124
  // `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>/lib/node_modules`
84
- const nodeModules = path.dirname(packageDir.includes(`${path.sep}@`) ? path.dirname(packageDir) : packageDir);
85
- return writable(packageDir) && writable(nodeModules);
125
+ const nodeModules = at >= 0 ? packageDir.slice(0, at + marker.length - 1) : path.dirname(packageDir);
126
+ if (!isWritable(packageDir) || !isWritable(nodeModules))
127
+ return false;
128
+ const prefix = installPrefixFor(packageDir);
129
+ // No `bin` yet is fine — npm creates it. An existing one we cannot write is not.
130
+ if (prefix) {
131
+ const bin = path.join(prefix, 'bin');
132
+ if (fs.existsSync(bin) && !isWritable(bin))
133
+ return false;
134
+ }
135
+ return true;
86
136
  }
87
137
  /**
88
138
  * Is something going to restart us?
@@ -140,17 +190,23 @@ const COMMAND_NAME = 'devbridge-runner';
140
190
  * command — the stable thing, referenced by a unit file we do not control —
141
191
  * points at the new one. So we follow the command.
142
192
  */
143
- async function commandOwner(exec) {
144
- let prefix;
145
- try {
146
- const result = await exec('npm', ['prefix', '-g'], {
147
- timeout: VERIFY_TIMEOUT_MS,
148
- env: npmEnv(),
149
- });
150
- prefix = result.stdout.trim();
151
- }
152
- catch {
153
- return null;
193
+ async function commandOwner(exec, installPrefix = null) {
194
+ // Where we actually live outranks what npm is configured to think. Under the
195
+ // dedicated-user layout `npm prefix -g` answers `/usr` — a directory that on
196
+ // such a host holds no runner at all — so asking npm first returned null and
197
+ // took the EEXIST rename-recovery and the unit repair down with it.
198
+ let prefix = installPrefix;
199
+ if (!prefix) {
200
+ try {
201
+ const result = await exec('npm', ['prefix', '-g'], {
202
+ timeout: VERIFY_TIMEOUT_MS,
203
+ env: npmEnv(),
204
+ });
205
+ prefix = result.stdout.trim();
206
+ }
207
+ catch {
208
+ return null;
209
+ }
154
210
  }
155
211
  if (!prefix)
156
212
  return null;
@@ -167,13 +223,55 @@ async function commandOwner(exec) {
167
223
  return null;
168
224
  }
169
225
  }
170
- function installArgs(source) {
226
+ function installArgs(source, prefix) {
171
227
  // `--ignore-scripts` matches the documented install: this package and its whole
172
228
  // tree have no install/postinstall scripts, so nothing legitimate is skipped —
173
229
  // and an update pulled over the network gets no chance to run anything at
174
230
  // install time. `--loglevel=error` because npm's ERESOLVE warning about zod is
175
231
  // expected, harmless and long enough to bury the line that matters.
176
- return ['install', '-g', '--ignore-scripts', '--loglevel=error', source];
232
+ //
233
+ // `--prefix` names the prefix the runner is ALREADY installed in. Without it
234
+ // npm uses its configured global prefix, which on a dedicated-user install is
235
+ // a directory the daemon cannot write — and, on the rarer host where it can,
236
+ // npm cheerfully installs a SECOND copy somewhere the service does not exec,
237
+ // reports success, and the runner restarts on the old version forever.
238
+ return [
239
+ 'install',
240
+ '-g',
241
+ '--ignore-scripts',
242
+ '--loglevel=error',
243
+ ...(prefix ? ['--prefix', prefix] : []),
244
+ source,
245
+ ];
246
+ }
247
+ /**
248
+ * The command a person can paste on the server to update this exact machine.
249
+ *
250
+ * Built by the runner rather than by the dashboard because only the runner
251
+ * knows the three things that make it correct: which prefix it lives in, which
252
+ * user it runs as, and that user's uid for `XDG_RUNTIME_DIR`. The dashboard
253
+ * used to assemble a generic `npm install -g <url> && systemctl --user restart`
254
+ * and then patch it with string surgery — which produced a command that could
255
+ * not work on precisely the machines that needed it.
256
+ */
257
+ export function manualUpdateCommand(tarballUrl, options = {}) {
258
+ const packageDir = options.packageDir === undefined ? resolveInstalledPackageDir() : options.packageDir;
259
+ const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
260
+ const user = options.user ?? os.userInfo().username;
261
+ const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
262
+ const install = ['npm install -g --ignore-scripts --loglevel=error']
263
+ .concat(prefix ? [`--prefix ${prefix}`] : [])
264
+ .concat([tarballUrl])
265
+ .join(' ');
266
+ const restart = 'systemctl --user restart devbridge-runner';
267
+ // Writable by us → one line, run as ourselves. Not writable → the install
268
+ // half needs root and the restart half needs the daemon's own user, and
269
+ // «run it as root» alone is how people ended up with a new package and an
270
+ // old process still running.
271
+ if (installIsWritable(packageDir))
272
+ return `${install} && ${restart}`;
273
+ const runtime = uid >= 0 ? `/run/user/${uid}` : `/run/user/$(id -u ${user})`;
274
+ return `sudo ${install} && sudo -iu ${user} env XDG_RUNTIME_DIR=${runtime} ${restart}`;
177
275
  }
178
276
  /**
179
277
  * Install a global package, clearing the way if a DIFFERENTLY-NAMED build of this
@@ -186,25 +284,22 @@ function installArgs(source) {
186
284
  * one owner instead of two, which is also the only state the NEXT update can
187
285
  * work from.
188
286
  */
189
- async function installGlobal(exec, source) {
287
+ async function installGlobal(exec, source, prefix) {
190
288
  try {
191
- await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
289
+ await exec('npm', installArgs(source, prefix), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
192
290
  return;
193
291
  }
194
292
  catch (error) {
195
293
  if (!/EEXIST/i.test(describe(error)))
196
294
  throw error;
197
- const owner = await commandOwner(exec);
295
+ const owner = await commandOwner(exec, prefix);
198
296
  if (!owner)
199
297
  throw error;
200
298
  log.warn('self-update: the command belongs to another package — retiring it', {
201
299
  package: owner.name,
202
300
  });
203
- await exec('npm', ['uninstall', '-g', '--loglevel=error', owner.name], {
204
- timeout: NPM_TIMEOUT_MS,
205
- env: npmEnv(),
206
- });
207
- await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
301
+ await exec('npm', ['uninstall', '-g', '--loglevel=error', ...(prefix ? ['--prefix', prefix] : []), owner.name], { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
302
+ await exec('npm', installArgs(source, prefix), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
208
303
  }
209
304
  }
210
305
  /**
@@ -256,13 +351,12 @@ export async function selfUpdate(options) {
256
351
  // runner's own user.
257
352
  if (!installIsWritable(packageDir)) {
258
353
  const user = os.userInfo().username;
259
- const uid = typeof process.getuid === 'function' ? process.getuid() : -1;
260
354
  return fail(`The runner package in ${packageDir} belongs to another user, and this daemon runs as ${user}, ` +
261
- 'so it cannot replace itself. Update it on the server in two steps — install as root:\n' +
262
- ` npm install -g --ignore-scripts --loglevel=error ${options.tarballUrl}\n` +
263
- 'then restart the service as the runner’s own user:\n' +
264
- ` sudo -iu ${user} env XDG_RUNTIME_DIR=/run/user/${uid >= 0 ? uid : '$(id -u ' + user + ')'} systemctl --user restart devbridge-runner`);
355
+ 'so it cannot replace itself. Run this on the server instead:\n ' +
356
+ manualUpdateCommand(options.tarballUrl, { packageDir }));
265
357
  }
358
+ // Derived once and threaded through every npm call below.
359
+ const prefix = installPrefixFor(packageDir);
266
360
  // Pack the current version FIRST: without a rollback artefact there is no
267
361
  // honest way back if the new build turns out to be broken.
268
362
  const rollbackDir = path.join(stateDir(), 'rollback');
@@ -287,7 +381,7 @@ export async function selfUpdate(options) {
287
381
  return fail('Could not prepare a rollback copy of the current version — update aborted');
288
382
  }
289
383
  try {
290
- await installGlobal(exec, options.tarballUrl);
384
+ await installGlobal(exec, options.tarballUrl, prefix);
291
385
  }
292
386
  catch (error) {
293
387
  return fail(`Install failed: ${describe(error)}`, { rollbackTarball });
@@ -295,7 +389,7 @@ export async function selfUpdate(options) {
295
389
  // Where the new build actually landed. After a rename `packageDir` is the
296
390
  // directory we just retired, so its manifest would report the OLD version —
297
391
  // and the smoke test below would run code that no longer exists.
298
- const installed = await commandOwner(exec);
392
+ const installed = await commandOwner(exec, prefix);
299
393
  const newPackageDir = installed?.dir ?? packageDir;
300
394
  const toVersion = readVersion(newPackageDir) ?? undefined;
301
395
  // The real test: does the newly installed build start? `--version` loads the
@@ -318,12 +412,12 @@ export async function selfUpdate(options) {
318
412
  // Through the same door as the install above: if the failed update renamed
319
413
  // the package, the command now belongs to the new name and putting the old
320
414
  // one back hits the very same EEXIST.
321
- await installGlobal(exec, rollbackTarball);
415
+ await installGlobal(exec, rollbackTarball, prefix);
322
416
  return fail(`The new version did not start (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
323
417
  }
324
418
  catch (rollbackError) {
325
419
  return fail(`The new version did not start (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
326
- `Restore it on the server with: npm install -g ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
420
+ `Restore it on the server with: npm install -g${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
327
421
  }
328
422
  }
329
423
  // The service unit may be pinned to a file inside the directory this update
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.26.0";
1
+ export declare const RUNNER_VERSION = "0.27.0";
2
2
  //# sourceMappingURL=version.d.ts.map