@mohou/host 1.0.20 → 1.0.22

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": "@mohou/host",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -49,11 +49,11 @@
49
49
  "react-dom": "^19.2.0",
50
50
  "tailwindcss": "^4.3.3",
51
51
  "tw-animate-css": "^1.4.0",
52
- "@mohou/app-view": "^1.0.20",
53
- "@mohou/contract": "^1.0.20",
54
- "@mohou/runtime-provider": "^1.0.20",
55
- "@mohou/ui": "^1.0.20",
56
- "@mohou/mcp-client": "^1.0.20"
52
+ "@mohou/app-view": "^1.0.22",
53
+ "@mohou/contract": "^1.0.22",
54
+ "@mohou/runtime-provider": "^1.0.22",
55
+ "@mohou/mcp-client": "^1.0.22",
56
+ "@mohou/ui": "^1.0.22"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/better-sqlite3": "^9.6.0",
@@ -25,6 +25,12 @@ export interface HostPolicy {
25
25
  }
26
26
  /** Absent, or `default`, means the builtin library. Not required to boot. */
27
27
  defaultWorkbenchId?: string
28
+ /**
29
+ * Registry for this product's own update check and install, when the owner wants a mirror.
30
+ * Absent or empty follows the install prefix's build-time value. Our own npm children only:
31
+ * `~/.npmrc` and an app's own dependency installs are untouched.
32
+ */
33
+ updateRegistry?: string
28
34
  }
29
35
 
30
36
  /** Seed numbers and names. Host policy, not a locked product value. */
package/src/http/bind.ts CHANGED
@@ -15,7 +15,7 @@ import { readVendorFile } from './ports.ts'
15
15
  import type { LoopbackPorts } from './ports.ts'
16
16
  import { authoringTokenMatches } from './guard.ts'
17
17
  import { probeBrain, type HostPolicy } from '../host/config.ts'
18
- import { checkPackageUpdate, discardUpdateResult, readUpdateResult, stagePackageUpdate } from './updates.ts'
18
+ import { checkPackageUpdate, discardUpdateResult, readUpdateResult, readUpdateSource, stagePackageUpdate, updateEnv } from './updates.ts'
19
19
  import { McpError } from '@mohou/mcp-client'
20
20
 
21
21
  import { admitMcpText } from '../host/mcp-import.ts'
@@ -147,15 +147,16 @@ export function bindLoopback(input: {
147
147
  subscribeHost: listener => input.author.hostEvents.subscribe(listener),
148
148
  subscribeFrames: listener => forwardFrames(input.author.appEvents, listener),
149
149
  subscribeApp: (appId, since, listener) => input.author.appEvents.subscribe(appId, since, listener),
150
+ updateSource: () => Promise.resolve(readUpdateSource(updateEnv(input.policy()))),
150
151
  checkUpdate: async () => {
151
152
  const attempt = readUpdateResult(input.runtimeRoot)
152
153
  return {
153
- ...await checkPackageUpdate(),
154
+ ...await checkPackageUpdate(undefined, updateEnv(input.policy())),
154
155
  ...attempt === undefined ? {} : { lastAttempt: attempt },
155
156
  }
156
157
  },
157
158
  installUpdate: (version) => {
158
- stagePackageUpdate(version)
159
+ stagePackageUpdate(version, updateEnv(input.policy()))
159
160
  if (input.restart === undefined) return Promise.resolve()
160
161
  const restart = input.restart
161
162
  setTimeout(() => {
package/src/http/owner.ts CHANGED
@@ -36,9 +36,10 @@ export function mountOwner(app: Hono<HostEnv>, ports: LoopbackPorts): void {
36
36
  if (denied !== undefined) return denied
37
37
  return writeConfig(c, ports)
38
38
  })
39
- app.get(httpLayout.about, c => c.json({
39
+ app.get(httpLayout.about, async c => c.json({
40
40
  ok: true,
41
41
  ...aboutInfo(),
42
+ source: await ports.updateSource(),
42
43
  authoring: {
43
44
  url: `http://127.0.0.1:${ports.readPolicy().hostPort}${httpLayout.mcp}`,
44
45
  token: ports.authoringToken,
package/src/http/ports.ts CHANGED
@@ -11,7 +11,7 @@ import type { CredentialListing } from '../credentials/provider.ts'
11
11
  import type { McpCheck, McpEditorServer } from '../host/mcp-editor.ts'
12
12
  import type { McpLoadFailure } from '../host/mcp.ts'
13
13
  import type { AppPin } from '../theme/pin.ts'
14
- import type { UpdateCheck } from './updates.ts'
14
+ import type { UpdateCheck, UpdateSource } from './updates.ts'
15
15
 
16
16
  const require = createRequire(import.meta.url)
17
17
 
@@ -67,6 +67,8 @@ export interface LoopbackPorts {
67
67
  subscribeFrames(listener: (event: unknown) => void): () => void
68
68
  subscribeApp(appId: string, since: number, listener: (event: unknown) => void): () => void
69
69
  checkUpdate(): Promise<UpdateCheck>
70
+ /** Where an update would come from. Reads the prefix; no network. */
71
+ updateSource(): Promise<UpdateSource>
70
72
  installUpdate?(version: string): Promise<void>
71
73
  /** The panel showed the launcher's last attempt. The record is dropped, not shown twice. */
72
74
  ackUpdate?(at: number): Promise<void>
package/src/http/reply.ts CHANGED
@@ -30,11 +30,15 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
30
30
  export function mergePolicy(current: unknown, body: Record<string, unknown>): Record<string, unknown> {
31
31
  const base = isRecord(current) ? current : {}
32
32
  const runtimeRoot = typeof base.runtimeRoot === 'string' ? base.runtimeRoot : undefined
33
+ const { updateRegistry: rawRegistry, ...rest } = body
34
+ const updateRegistry = typeof rawRegistry === 'string' ? rawRegistry.trim() : undefined
33
35
  const provider = isRecord(body.runtimeProvider) ? body.runtimeProvider : undefined
34
36
  const previous = isRecord(base.runtimeProvider) ? base.runtimeProvider : undefined
35
37
  return {
36
38
  ...base,
37
- ...body,
39
+ ...rest,
40
+ // An empty string means "the packaged default". A non-string never clobbers the stored value.
41
+ ...updateRegistry === undefined ? {} : { updateRegistry },
38
42
  ...runtimeRoot === undefined ? {} : { runtimeRoot },
39
43
  ...provider === undefined || previous === undefined ? {} : {
40
44
  runtimeProvider: {
@@ -5,6 +5,12 @@ import path from 'node:path'
5
5
  import { homePackagesDir, hostUpdateResultPath } from '../host/layout.ts'
6
6
  import { aboutInfo } from './ports.ts'
7
7
 
8
+ /**
9
+ * Ask the install channel once. A dev tree with no prefix still asks the registry and cannot install.
10
+ * The budget is host policy, not a locked number: a mirror's first response is often slow.
11
+ */
12
+ export const updateCheckTimeoutMs = 8_000
13
+
8
14
  /** Why an install did not finish. The panel owns the wording of each code. */
9
15
  export const updateFailureCodes = ['timeout', 'exit', 'prepare', 'closed', 'leftover', 'verify', 'boot'] as const
10
16
 
@@ -32,6 +38,15 @@ export type UpdateAttempt =
32
38
  readonly at: number
33
39
  }
34
40
 
41
+ /**
42
+ * Where this install updates from. `none` is a source tree with no install prefix,
43
+ * which cannot install anything.
44
+ */
45
+ export type UpdateSource =
46
+ | { readonly channel: 'registry'; readonly registry: string }
47
+ | { readonly channel: 'tarball'; readonly tarballDir: string }
48
+ | { readonly channel: 'none' }
49
+
35
50
  export interface UpdateCheck {
36
51
  readonly name: string
37
52
  readonly current: string
@@ -39,6 +54,9 @@ export interface UpdateCheck {
39
54
  readonly updateAvailable: boolean
40
55
  readonly channel?: 'registry' | 'tarball'
41
56
  readonly installable?: boolean
57
+ /** Set when the install prefix names one, so a panel can say where an update comes from. */
58
+ readonly registry?: string
59
+ readonly tarballDir?: string
42
60
  readonly error?: string
43
61
  /** The last install this machine recorded, when one is on disk. */
44
62
  readonly lastAttempt?: UpdateAttempt
@@ -62,12 +80,14 @@ export async function checkPackageUpdate(
62
80
  if (about.name.length === 0) return { ...empty, error: 'package name is missing' }
63
81
  const prefix = readPrefixUpdate(cwd, env)
64
82
  if (prefix?.channel === 'tarball') {
65
- const latest = newestTarball(prefix.tarballDir ?? homePackagesDir(homedir()), 'shell')
83
+ const tarballDir = prefix.tarballDir ?? homePackagesDir(homedir())
84
+ const latest = newestTarball(tarballDir, 'shell')
66
85
  return {
67
86
  name: '@mohou/shell',
68
87
  current: about.current,
69
88
  latest,
70
89
  channel: 'tarball',
90
+ tarballDir,
71
91
  installable: true,
72
92
  updateAvailable: latest !== null && compareVersion(latest, about.current) > 0,
73
93
  }
@@ -77,9 +97,15 @@ export async function checkPackageUpdate(
77
97
  const name = prefix === undefined ? about.name : '@mohou/shell'
78
98
  try {
79
99
  const response = await fetch(`${registry.replace(/\/$/, '')}/${name}/latest`, {
80
- signal: AbortSignal.timeout(3_000),
100
+ signal: AbortSignal.timeout(updateCheckTimeoutMs),
81
101
  })
82
- if (!response.ok) return { ...empty, name, ...prefix === undefined ? {} : { channel: prefix.channel }, installable: prefix !== undefined, error: `package registry returned ${response.status}` }
102
+ if (!response.ok) return {
103
+ ...empty,
104
+ name,
105
+ ...prefix === undefined ? {} : { channel: prefix.channel, registry: prefix.registry ?? 'https://registry.npmjs.org' },
106
+ installable: prefix !== undefined,
107
+ error: `package registry returned ${response.status}`,
108
+ }
83
109
  const body = await response.json() as { version?: unknown }
84
110
  const latest = typeof body.version === 'string' ? body.version : null
85
111
  return {
@@ -87,14 +113,41 @@ export async function checkPackageUpdate(
87
113
  current: about.current,
88
114
  latest,
89
115
  channel: prefix?.channel ?? 'registry',
116
+ ...prefix === undefined ? {} : { registry: prefix.registry ?? 'https://registry.npmjs.org' },
90
117
  installable: prefix !== undefined,
91
- updateAvailable: latest !== null && latest !== about.current,
118
+ // Newer only. A mirror syncs late, and an older `latest` is not an update.
119
+ updateAvailable: latest !== null && compareVersion(latest, about.current) > 0,
92
120
  }
93
121
  } catch (error) {
94
- return { ...empty, name, ...prefix === undefined ? {} : { channel: prefix.channel }, installable: prefix !== undefined, error: error instanceof Error ? error.message : 'update check failed' }
122
+ return {
123
+ ...empty,
124
+ name,
125
+ ...prefix === undefined ? {} : { channel: prefix.channel, registry: prefix.registry ?? 'https://registry.npmjs.org' },
126
+ installable: prefix !== undefined,
127
+ error: error instanceof Error ? error.message : 'update check failed',
128
+ }
95
129
  }
96
130
  }
97
131
 
132
+ export function updateEnv(policy: { readonly updateRegistry?: string }, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
133
+ const registry = policy.updateRegistry?.trim()
134
+ return registry === undefined || registry.length === 0 ? env : { ...env, MINI_APP_NPM_REGISTRY: registry }
135
+ }
136
+
137
+ /**
138
+ * Where this install would update from. Reads the install prefix; asks no registry, so a
139
+ * panel can show the source while the network is slow or down.
140
+ * @param env - process environment; `MINI_APP_TARBALL_DIR` overrides the package folder, and
141
+ * `MINI_APP_NPM_REGISTRY` overrides the prefix's registry (that is how the policy reaches us)
142
+ * @param cwd - directory the sidecar runs in
143
+ */
144
+ export function readUpdateSource(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): UpdateSource {
145
+ const prefix = readPrefixUpdate(cwd, env)
146
+ if (prefix === undefined) return { channel: 'none' }
147
+ if (prefix.channel === 'tarball') return { channel: 'tarball', tarballDir: prefix.tarballDir ?? homePackagesDir(homedir()) }
148
+ return { channel: 'registry', registry: prefix.registry ?? 'https://registry.npmjs.org' }
149
+ }
150
+
98
151
  /**
99
152
  * Read the launcher's record of the last install. A missing, unreadable, or malformed file reads as
100
153
  * no attempt: the panel shows nothing rather than a guessed outcome.
@@ -232,10 +285,13 @@ function readPrefixUpdate(cwd: string, env: NodeJS.ProcessEnv): PrefixUpdate | u
232
285
  const packages = Object.keys(parsed.dependencies ?? {}).filter(name => name.startsWith('@mohou/'))
233
286
  const override = env.MINI_APP_TARBALL_DIR
234
287
  const tarballDir = override !== undefined && override.length > 0 ? override : parsed.mohou?.tarballDir
288
+ const registry = env.MINI_APP_NPM_REGISTRY?.trim()
235
289
  return {
236
290
  dir,
237
291
  channel,
238
- ...typeof parsed.mohou?.registry === 'string' ? { registry: parsed.mohou.registry } : {},
292
+ ...registry !== undefined && registry.length > 0
293
+ ? { registry }
294
+ : typeof parsed.mohou?.registry === 'string' ? { registry: parsed.mohou.registry } : {},
239
295
  ...tarballDir === undefined ? {} : { tarballDir },
240
296
  packages: packages.length === 0 ? ['@mohou/shell'] : packages,
241
297
  }