wherdr 1.3.1 → 1.3.2
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/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{WLQjK8uJ.js → 8ksjcuDu.js} +51 -41
- package/.output/public/_nuxt/B7QP1TFs.js +23 -0
- package/.output/public/_nuxt/{CiYar6hg.js → BOpBIziZ.js} +2 -2
- package/.output/public/_nuxt/BWXdFfeg.js +17 -0
- package/.output/public/_nuxt/COFCNFpG.js +83 -0
- package/.output/public/_nuxt/{C2T9fQ4f.js → Cd8j9O5w.js} +1 -1
- package/.output/public/_nuxt/{C12eXl5z.js → CkZ8t6uB.js} +1 -1
- package/.output/public/_nuxt/{CctS2Xn6.js → D4Yz4rat.js} +4 -4
- package/.output/public/_nuxt/{B6qBWHWB.js → D7sxNXx1.js} +1 -1
- package/.output/public/_nuxt/DViwOclv.js +1 -0
- package/.output/public/_nuxt/DoD7Qb5P.js +30 -0
- package/.output/public/_nuxt/{BMIe8p_3.js → DyBZDSCV.js} +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/7afbf062-5369-4002-b20d-7a582ff6e0f1.json +1 -0
- package/.output/public/_nuxt/{entry.cKR16VqG.css → entry.Crt1r4l3.css} +1 -1
- package/.output/public/_nuxt/hePW80VL.js +1 -0
- package/.output/public/sw.js +1 -1
- package/.output/server/chunks/_/branding.mjs +1 -1
- package/.output/server/chunks/_/package.mjs +96 -0
- package/.output/server/chunks/_/plugins.mjs +1 -1
- package/.output/server/chunks/_/projectBoard.mjs +1 -1
- package/.output/server/chunks/_/projectsPlugin.mjs +1 -1
- package/.output/server/chunks/_/selfupdate.mjs +82 -0
- package/.output/server/chunks/_/updates.mjs +50 -0
- package/.output/server/chunks/_/worktrees.mjs +1 -1
- package/.output/server/chunks/_/ws.mjs +1 -1
- package/.output/server/chunks/nitro/nitro.mjs +1670 -899
- package/.output/server/chunks/raw/herdr-plugin.mjs +1 -1
- package/.output/server/chunks/routes/api/_..._.mjs +5 -1
- package/.output/server/chunks/routes/api/agents.post.mjs +5 -1
- package/.output/server/chunks/routes/api/ask-tab.post.mjs +5 -1
- package/.output/server/chunks/routes/api/attach.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/disable.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/lock-all.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/lock.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/login/options.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/login/verify.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/max-session.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/register/options.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/register/verify.post.mjs +5 -1
- package/.output/server/chunks/routes/api/auth/status.get.mjs +5 -1
- package/.output/server/chunks/routes/api/changes.get.mjs +5 -1
- package/.output/server/chunks/routes/api/chat/image.get.mjs +5 -1
- package/.output/server/chunks/routes/api/chat.get.mjs +5 -1
- package/.output/server/chunks/routes/api/choose.post.mjs +5 -1
- package/.output/server/chunks/routes/api/claude-statusline.post.mjs +5 -1
- package/.output/server/chunks/routes/api/close/status.get.mjs +5 -1
- package/.output/server/chunks/routes/api/close.post.mjs +5 -1
- package/.output/server/chunks/routes/api/codex-update.post.mjs +5 -1
- package/.output/server/chunks/routes/api/commands.get.mjs +5 -1
- package/.output/server/chunks/routes/api/config.get.mjs +5 -1
- package/.output/server/chunks/routes/api/dirs.get.mjs +5 -1
- package/.output/server/chunks/routes/api/dismiss.post.mjs +5 -1
- package/.output/server/chunks/routes/api/effort.post.mjs +5 -1
- package/.output/server/chunks/routes/api/efforts.get.mjs +5 -1
- package/.output/server/chunks/routes/api/file.post.mjs +5 -1
- package/.output/server/chunks/routes/api/gitroot.get.mjs +5 -1
- package/.output/server/chunks/routes/api/health.get.mjs +36 -0
- package/.output/server/chunks/routes/api/index.get.mjs +5 -1
- package/.output/server/chunks/routes/api/input.post.mjs +5 -1
- package/.output/server/chunks/routes/api/interrupt.post.mjs +5 -1
- package/.output/server/chunks/routes/api/isgit.get.mjs +5 -1
- package/.output/server/chunks/routes/api/machine/awake.get.mjs +5 -1
- package/.output/server/chunks/routes/api/machine/awake.post.mjs +5 -1
- package/.output/server/chunks/routes/api/machine/order.get.mjs +5 -1
- package/.output/server/chunks/routes/api/machine/order.post.mjs +5 -1
- package/.output/server/chunks/routes/api/machine/rename.post.mjs +5 -1
- package/.output/server/chunks/routes/api/menu.post.mjs +5 -1
- package/.output/server/chunks/routes/api/model.post.mjs +5 -1
- package/.output/server/chunks/routes/api/models.get.mjs +5 -1
- package/.output/server/chunks/routes/api/nav.post.mjs +5 -1
- package/.output/server/chunks/routes/api/onboarding.get.mjs +5 -1
- package/.output/server/chunks/routes/api/onboarding.post.mjs +5 -1
- package/.output/server/chunks/routes/api/phone.get.mjs +5 -6
- package/.output/server/chunks/routes/api/phone.post.mjs +5 -6
- package/.output/server/chunks/routes/api/plugins/actions.get.mjs +5 -1
- package/.output/server/chunks/routes/api/plugins/invoke.post.mjs +5 -1
- package/.output/server/chunks/routes/api/plugins/profiles.get.mjs +5 -1
- package/.output/server/chunks/routes/api/plugins/projects.get.mjs +5 -1
- package/.output/server/chunks/routes/api/plugins/projects.post.mjs +5 -1
- package/.output/server/chunks/routes/api/plugins/thread-limits.get.mjs +6 -2
- package/.output/server/chunks/routes/api/plugins/thread-limits.post.mjs +6 -2
- package/.output/server/chunks/routes/api/project/report.get.mjs +5 -1
- package/.output/server/chunks/routes/api/prompt.post.mjs +6 -2
- package/.output/server/chunks/routes/api/push/quiet.post.mjs +6 -2
- package/.output/server/chunks/routes/api/push/subscribe.post.mjs +6 -2
- package/.output/server/chunks/routes/api/push/test.post.mjs +6 -2
- package/.output/server/chunks/routes/api/push/unsubscribe.post.mjs +6 -2
- package/.output/server/chunks/routes/api/quotas.get.mjs +6 -2
- package/.output/server/chunks/routes/api/rename.post.mjs +5 -1
- package/.output/server/chunks/routes/api/requeue.post.mjs +6 -2
- package/.output/server/chunks/routes/api/restart.get.mjs +5 -1
- package/.output/server/chunks/routes/api/restart.post.mjs +5 -1
- package/.output/server/chunks/routes/api/reveal.post.mjs +5 -1
- package/.output/server/chunks/routes/api/screen-key.post.mjs +6 -2
- package/.output/server/chunks/routes/api/screen.get.mjs +6 -2
- package/.output/server/chunks/routes/api/search.get.mjs +6 -2
- package/.output/server/chunks/routes/api/seen.post.mjs +6 -2
- package/.output/server/chunks/routes/api/sessions.get.mjs +6 -2
- package/.output/server/chunks/routes/api/sessions.post.mjs +6 -2
- package/.output/server/chunks/routes/api/space.post.mjs +6 -2
- package/.output/server/chunks/routes/api/state.get.mjs +6 -2
- package/.output/server/chunks/routes/api/unqueue.post.mjs +6 -2
- package/.output/server/chunks/routes/api/update/status.get.mjs +38 -0
- package/.output/server/chunks/routes/api/update.get.mjs +11 -50
- package/.output/server/chunks/routes/api/update.post.mjs +41 -0
- package/.output/server/chunks/routes/api/upload.post.mjs +5 -1
- package/.output/server/chunks/routes/api/worktrees/remove.post.mjs +5 -1
- package/.output/server/chunks/routes/api/worktrees.get.mjs +5 -1
- package/.output/server/chunks/routes/favicon.ico.get.mjs +5 -1
- package/.output/server/chunks/routes/icons/_name_.get.mjs +6 -2
- package/.output/server/chunks/routes/renderer.mjs +7 -3
- package/.output/server/chunks/routes/uploads/_...name_.get.mjs +6 -2
- package/.output/server/chunks/routes/ws/_..._.mjs +6 -2
- package/.output/server/chunks/routes/ws/events.mjs +6 -2
- package/.output/server/chunks/routes/ws/mirror.mjs +6 -2
- package/.output/server/chunks/routes/ws/term.mjs +8 -4
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/index.mjs +6 -2
- package/.output/server/package.json +1 -1
- package/README.md +110 -23
- package/bin/lib/commands.mjs +42 -8
- package/bin/lib/core.mjs +17 -2
- package/bin/lib/tailnet.d.mts +12 -3
- package/bin/lib/tailnet.mjs +103 -25
- package/bin/lib/updater.mjs +267 -0
- package/package.json +2 -1
- package/.output/public/_nuxt/BpQH8Ho1.js +0 -1
- package/.output/public/_nuxt/BrAeJIZR.js +0 -30
- package/.output/public/_nuxt/CYxHnMNH.js +0 -83
- package/.output/public/_nuxt/DLLQgP0d.js +0 -1
- package/.output/public/_nuxt/Dwh-l2rQ.js +0 -17
- package/.output/public/_nuxt/builds/meta/6466b0ab-7935-460c-abdf-2ea632dae6d0.json +0 -1
- package/.output/public/_nuxt/whFLy_Ne.js +0 -23
- package/.output/server/chunks/_/phone.mjs +0 -343
package/bin/lib/core.mjs
CHANGED
|
@@ -17,6 +17,18 @@ export function homebrewInstall(file) {
|
|
|
17
17
|
return /[\\/]Cellar[\\/]wherdr[\\/]/.test(file)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// How this copy of wherdr was installed (shared/updates.ts InstallMode), from
|
|
21
|
+
// the path of its bin/wherdr.mjs: the Homebrew Cellar; the copy the Herdr
|
|
22
|
+
// plugin keeps in <wherdr dir>/app; `npm i -g` (<prefix>/lib/node_modules/wherdr,
|
|
23
|
+
// never a temporary npx folder); otherwise a temporary package (npx, bunx, pnpm dlx).
|
|
24
|
+
export function installKind(bin, dir) {
|
|
25
|
+
if (homebrewInstall(bin)) return 'brew'
|
|
26
|
+
const root = path.resolve(bin, '..', '..')
|
|
27
|
+
if (root === path.join(dir, 'app')) return 'plugin'
|
|
28
|
+
if (/[\\/]lib[\\/]node_modules[\\/]wherdr$/.test(root) && !/[\\/]_npx[\\/]/.test(root)) return 'npm-global'
|
|
29
|
+
return 'npm'
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
export const COMMANDS = ['run', 'start', 'stop', 'restart', 'status', 'logs', 'open', 'phone', 'panel', 'service', 'doctor', 'help', 'version']
|
|
21
33
|
const VALUE_OPTIONS = { '--port': 'port', '--host': 'host', '--data-dir': 'dataDir', '--session': 'session', '--lines': 'lines', '-n': 'lines' }
|
|
22
34
|
const FLAGS = { '-h': 'help', '--help': 'help', '-v': 'version', '--version': 'version', '-f': 'follow', '--follow': 'follow' }
|
|
@@ -98,8 +110,11 @@ export function serverEnv(opts, env, home = os.homedir(), bin = BIN) {
|
|
|
98
110
|
out.NODE_ENV = 'production'
|
|
99
111
|
// Open WebSockets do not hold Ctrl+C for 30 s; clients reconnect on their own.
|
|
100
112
|
out.NITRO_SHUTDOWN_TIMEOUT ??= '1500'
|
|
101
|
-
// Update command offered in the app (shared/updates.ts)
|
|
102
|
-
|
|
113
|
+
// Update command, or one-tap update, offered in the app (shared/updates.ts):
|
|
114
|
+
// the package to replace and the folder of its state and log (server/utils/selfupdate.ts).
|
|
115
|
+
out.WHERDR_INSTALL = installKind(bin, dir)
|
|
116
|
+
out.WHERDR_ROOT = path.resolve(bin, '..', '..')
|
|
117
|
+
out.WHERDR_DIR = dir
|
|
103
118
|
// Nitro reads NITRO_PORT / NITRO_HOST before PORT / HOST.
|
|
104
119
|
delete out.NITRO_PORT
|
|
105
120
|
delete out.NITRO_HOST
|
package/bin/lib/tailnet.d.mts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// Types of tailnet.mjs, for the server (server/utils/phone.ts).
|
|
2
|
-
|
|
2
|
+
// funnel: also published on the Internet by `tailscale funnel` (never adopted).
|
|
3
|
+
export interface PhoneAddress { url: string, served: boolean, httpsPort: string, funnel?: boolean }
|
|
3
4
|
export interface Tailnet {
|
|
4
5
|
installed: boolean
|
|
5
|
-
bin?: string
|
|
6
|
+
bin?: string | null
|
|
7
|
+
// Tailscale's socket, when the state was read there (no CLI: Docker).
|
|
8
|
+
socket?: string | null
|
|
6
9
|
name?: string | null
|
|
7
10
|
connected?: boolean
|
|
8
11
|
https?: boolean
|
|
9
12
|
phone?: PhoneAddress | null
|
|
10
13
|
taken?: boolean
|
|
14
|
+
// Addresses published for wherdr's port; null when they could not be read.
|
|
15
|
+
served?: string[] | null
|
|
11
16
|
}
|
|
12
17
|
export type Reach = 'ok' | 'host' | 'other' | 'unreachable'
|
|
13
18
|
export type ReachCause = 'dns' | 'refused' | 'timeout' | 'cert' | 'tls' | 'network'
|
|
@@ -15,18 +20,22 @@ export interface Probe { reach: Reach, cause?: ReachCause, status?: number }
|
|
|
15
20
|
export type ServeErrorCode = 'operator' | 'https' | 'offline'
|
|
16
21
|
|
|
17
22
|
export const LINKS: { download: string, https: string, operator: string }
|
|
23
|
+
export const TAILSCALED_SOCKET: string
|
|
24
|
+
export function tailscaledSocket(env?: Record<string, string | undefined>, exists?: (p: string) => boolean): string | null
|
|
18
25
|
export function tailscaleBin(env?: Record<string, string | undefined>, exists?: (p: string) => boolean): string | null
|
|
19
26
|
export function tailnetStatus(statusJson: string): { name: string | null, connected: boolean, https: boolean }
|
|
20
27
|
export function phoneAddress(name: string | null, port: string | number, serveJson: string): PhoneAddress | null
|
|
28
|
+
export function servedAddresses(name: string | null, port: string | number, serveJson: string): string[] | null
|
|
21
29
|
export function servedPorts(serveJson: string): string[]
|
|
22
30
|
export function publishCommand(port: string | number, httpsPort?: string | number): string
|
|
23
31
|
export function publishArgs(port: string | number): string[]
|
|
24
32
|
export function unpublishArgs(httpsPort: string): string[]
|
|
25
33
|
export function tailnetUrl(text: unknown): string | null
|
|
26
34
|
export function serveError(output: string): { code: ServeErrorCode, link: string } | null
|
|
27
|
-
export function inspect(port: string | number, bin?: string | null): Promise<Tailnet>
|
|
35
|
+
export function inspect(port: string | number, bin?: string | null, socket?: string | null): Promise<Tailnet>
|
|
28
36
|
export function tailnetDomain(resolvConf: string): string | null
|
|
29
37
|
export function reachable(url: string, timeoutMs?: number): Promise<Reach>
|
|
38
|
+
export function healthReach(status: number, text: string): 'ok' | 'host' | null
|
|
30
39
|
export function probe(url: string, timeoutMs?: number): Promise<Probe>
|
|
31
40
|
export function failureCause(err: unknown): ReachCause
|
|
32
41
|
export function runTailscale(bin: string, args: string[], timeoutMs?: number): Promise<{ code: number, output: string }>
|
package/bin/lib/tailnet.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { execFile } from 'node:child_process'
|
|
8
8
|
import dns from 'node:dns'
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
|
+
import http from 'node:http'
|
|
10
11
|
import https from 'node:https'
|
|
11
12
|
import path from 'node:path'
|
|
12
13
|
import { promisify } from 'node:util'
|
|
@@ -26,6 +27,32 @@ export function tailscaleBin(env = process.env, exists = existsSync) {
|
|
|
26
27
|
return dirs.find(p => exists(p)) || null
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// Tailscale's own socket on Linux. Its local API answers the same JSON as the
|
|
31
|
+
// CLI: a Docker container has no `tailscale` command, but docker-compose.yml
|
|
32
|
+
// mounts this folder, so wherdr reads the state there. Anyone may read; the
|
|
33
|
+
// API also accepts changes from Tailscale's operator, but wherdr only ever
|
|
34
|
+
// sends GET requests to it (localApi below).
|
|
35
|
+
export const TAILSCALED_SOCKET = '/var/run/tailscale/tailscaled.sock'
|
|
36
|
+
export function tailscaledSocket(env = process.env, exists = existsSync) {
|
|
37
|
+
const file = env.WHERDR_TAILSCALED_SOCKET || TAILSCALED_SOCKET
|
|
38
|
+
return exists(file) ? file : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// GET on Tailscale's local API → the body, '' on any failure.
|
|
42
|
+
function localApi(socketPath, route, timeoutMs = 4000) {
|
|
43
|
+
const { promise, resolve } = Promise.withResolvers()
|
|
44
|
+
const req = http.get({ socketPath, path: `/localapi/v0/${route}`, headers: { host: 'local-tailscaled.sock' }, timeout: timeoutMs }, (res) => {
|
|
45
|
+
let text = ''
|
|
46
|
+
res.setEncoding('utf8')
|
|
47
|
+
res.on('data', (c) => { if (text.length < 1 << 20) text += c })
|
|
48
|
+
res.on('end', () => resolve(res.statusCode === 200 ? text : ''))
|
|
49
|
+
res.on('error', () => resolve(''))
|
|
50
|
+
})
|
|
51
|
+
req.on('timeout', () => req.destroy())
|
|
52
|
+
req.on('error', () => resolve(''))
|
|
53
|
+
return promise
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
// `tailscale status --json` → { name, connected, https }: this machine's
|
|
30
57
|
// tailnet name (machine.tailnet.ts.net) or null, whether Tailscale is up,
|
|
31
58
|
// and whether the tailnet issues HTTPS certificates.
|
|
@@ -40,22 +67,51 @@ export function tailnetStatus(statusJson) {
|
|
|
40
67
|
}
|
|
41
68
|
}
|
|
42
69
|
|
|
70
|
+
// The HTTPS entries of a serve configuration that proxy to wherdr's local
|
|
71
|
+
// port on this machine's own name: [{ hostPort, httpsPort, funnel }], or null
|
|
72
|
+
// when the configuration could not be read (Tailscale stopped, not JSON).
|
|
73
|
+
// Background entries (`--bg`) and those of a `tailscale serve` still running
|
|
74
|
+
// in a terminal (Foreground) both count. `funnel`: also open to the Internet.
|
|
75
|
+
function wherdrEntries(name, port, serveJson) {
|
|
76
|
+
let config = null
|
|
77
|
+
try { config = JSON.parse(serveJson) } catch {}
|
|
78
|
+
if (!name || !config || typeof config !== 'object' || Array.isArray(config)) return null
|
|
79
|
+
const target = new RegExp(`^(https?://)?(127\\.0\\.0\\.1|localhost):${port}/?$`)
|
|
80
|
+
const out = []
|
|
81
|
+
for (const part of [config, ...Object.values(config.Foreground || {})]) {
|
|
82
|
+
for (const [hostPort, entry] of Object.entries(part?.Web || {})) {
|
|
83
|
+
const colon = hostPort.lastIndexOf(':')
|
|
84
|
+
if (hostPort.slice(0, colon).toLowerCase() !== name.toLowerCase()) continue
|
|
85
|
+
if (!Object.values(entry?.Handlers || {}).some(h => target.test(h?.Proxy || ''))) continue
|
|
86
|
+
out.push({ hostPort, httpsPort: hostPort.slice(colon + 1), funnel: Boolean(part.AllowFunnel?.[hostPort]) })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const addressOf = (name, httpsPort) => `https://${name}${httpsPort === '443' ? '' : `:${httpsPort}`}/`
|
|
93
|
+
|
|
43
94
|
// Phone address: the `tailscale serve` entry whose proxy targets wherdr's
|
|
44
95
|
// local port (served on any HTTPS port), else the address publishing would
|
|
45
96
|
// give (same port) with served: false. Null without a tailnet name.
|
|
46
97
|
export function phoneAddress(name, port, serveJson) {
|
|
47
98
|
if (!name) return null
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (const [hostPort, entry] of Object.entries(web)) {
|
|
52
|
-
if (!Object.values(entry?.Handlers || {}).some(h => target.test(h?.Proxy || ''))) continue
|
|
53
|
-
const servedPort = hostPort.slice(hostPort.lastIndexOf(':') + 1)
|
|
54
|
-
return { url: `https://${name}${servedPort === '443' ? '' : `:${servedPort}`}/`, served: true, httpsPort: servedPort }
|
|
55
|
-
}
|
|
99
|
+
const entries = wherdrEntries(name, port, serveJson) || []
|
|
100
|
+
const hit = entries.find(e => !e.funnel) || entries[0]
|
|
101
|
+
if (hit) return { url: addressOf(name, hit.httpsPort), served: true, httpsPort: hit.httpsPort, ...(hit.funnel ? { funnel: true } : {}) }
|
|
56
102
|
return { url: `https://${name}:${port}/`, served: false, httpsPort: String(port) }
|
|
57
103
|
}
|
|
58
104
|
|
|
105
|
+
// The addresses wherdr adopts by itself (server/utils/phone.ts): what
|
|
106
|
+
// `tailscale serve` publishes for its port on this machine's name, read from
|
|
107
|
+
// Tailscale, which only the owner of the machine configures. null: unknown
|
|
108
|
+
// (nothing is changed then); []: nothing is published. An address that
|
|
109
|
+
// `tailscale funnel` also opens to the Internet is never adopted.
|
|
110
|
+
export function servedAddresses(name, port, serveJson) {
|
|
111
|
+
const entries = wherdrEntries(name, port, serveJson)
|
|
112
|
+
return entries && entries.filter(e => !e.funnel).map(e => addressOf(name, e.httpsPort))
|
|
113
|
+
}
|
|
114
|
+
|
|
59
115
|
// HTTPS ports already published by `tailscale serve` (for anything).
|
|
60
116
|
export function servedPorts(serveJson) {
|
|
61
117
|
try {
|
|
@@ -92,16 +148,18 @@ export function serveError(output) {
|
|
|
92
148
|
return null
|
|
93
149
|
}
|
|
94
150
|
|
|
95
|
-
// Tailscale's state for wherdr's port: { installed, bin,
|
|
96
|
-
|
|
97
|
-
|
|
151
|
+
// Tailscale's state for wherdr's port: { installed, bin, socket, connected,
|
|
152
|
+
// https, name, phone, taken, served }. Read with the CLI, or without one
|
|
153
|
+
// (Docker) from Tailscale's socket.
|
|
154
|
+
export async function inspect(port, bin = tailscaleBin(), socket = bin ? null : tailscaledSocket()) {
|
|
155
|
+
if (!bin && !socket) return { installed: false }
|
|
98
156
|
const run = args => exec(bin, args, { encoding: 'utf8', timeout: 8000 }).then(r => r.stdout, () => '')
|
|
99
|
-
const status = tailnetStatus(await run(['status', '--json']))
|
|
100
|
-
const serve = status.name ? await run(['serve', 'status', '--json']) : ''
|
|
157
|
+
const status = tailnetStatus(bin ? await run(['status', '--json']) : await localApi(socket, 'status?peers=false'))
|
|
158
|
+
const serve = !status.name ? '' : bin ? await run(['serve', 'status', '--json']) : await localApi(socket, 'serve-config')
|
|
101
159
|
const phone = phoneAddress(status.name, port, serve)
|
|
102
160
|
// Publishing on wherdr's port would replace whatever else is served there.
|
|
103
161
|
const taken = Boolean(phone && !phone.served && servedPorts(serve).includes(String(port)))
|
|
104
|
-
return { installed: true, bin, ...status, phone, taken }
|
|
162
|
+
return { installed: true, bin, socket, ...status, phone, taken, served: servedAddresses(status.name, port, serve) }
|
|
105
163
|
}
|
|
106
164
|
|
|
107
165
|
// The tailnet's DNS suffix (tailnet.ts.net) from /etc/resolv.conf's search
|
|
@@ -152,29 +210,49 @@ export function failureCause(err) {
|
|
|
152
210
|
return 'network'
|
|
153
211
|
}
|
|
154
212
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
export function probe(url, timeoutMs = 8000) {
|
|
213
|
+
// GET of `route` on the address → { status, text }, or { cause } when it failed.
|
|
214
|
+
function fetchText(url, route, timeoutMs) {
|
|
158
215
|
const { promise, resolve } = Promise.withResolvers()
|
|
159
216
|
let u
|
|
160
|
-
try { u = new URL(
|
|
217
|
+
try { u = new URL(route, url) } catch { return Promise.resolve({ cause: 'network' }) }
|
|
161
218
|
// Accept JSON: a refused host then answers { code: 'host' }, not the HTML page.
|
|
162
219
|
const req = https.get(u, { lookup, timeout: timeoutMs, headers: { accept: 'application/json' } }, (res) => {
|
|
163
220
|
let text = ''
|
|
164
221
|
res.setEncoding('utf8')
|
|
165
222
|
res.on('data', (c) => { if (text.length < 65536) text += c })
|
|
166
|
-
res.on('end', () => {
|
|
167
|
-
|
|
168
|
-
if (status >= 200 && status < 300) resolve({ reach: text.includes('wherdr') ? 'ok' : 'other', status })
|
|
169
|
-
else resolve({ reach: status === 403 && /"code"\s*:\s*"host"/.test(text) ? 'host' : 'other', status })
|
|
170
|
-
})
|
|
171
|
-
res.on('error', e => resolve({ reach: 'unreachable', cause: failureCause(e) }))
|
|
223
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, text }))
|
|
224
|
+
res.on('error', e => resolve({ cause: failureCause(e) }))
|
|
172
225
|
})
|
|
173
226
|
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
174
|
-
req.on('error', e => resolve({
|
|
227
|
+
req.on('error', e => resolve({ cause: failureCause(e) }))
|
|
175
228
|
return promise
|
|
176
229
|
}
|
|
177
230
|
|
|
231
|
+
// What an answer of /api/health means: 'ok' (wherdr, and it accepts this
|
|
232
|
+
// address), 'host' (wherdr's host check refused it), or null: not a wherdr
|
|
233
|
+
// that has this route (versions up to 1.3.1, or another program).
|
|
234
|
+
export function healthReach(status, text) {
|
|
235
|
+
if (status >= 200 && status < 300 && /"name"\s*:\s*"wherdr"/.test(text)) return 'ok'
|
|
236
|
+
if (status === 403 && /"code"\s*:\s*"host"/.test(text)) return 'host'
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// reachable() with the details: { reach, cause?, status? }: `cause` when
|
|
241
|
+
// unreachable (failureCause), `status` the HTTP code when something answered.
|
|
242
|
+
// /api/health is asked first: it goes through wherdr's host check, which the
|
|
243
|
+
// static manifest does not (it answers for any host name). The manifest only
|
|
244
|
+
// recognizes an older wherdr without that route.
|
|
245
|
+
export async function probe(url, timeoutMs = 8000) {
|
|
246
|
+
const health = await fetchText(url, 'api/health', timeoutMs)
|
|
247
|
+
if (health.cause) return { reach: 'unreachable', cause: health.cause }
|
|
248
|
+
const reach = healthReach(health.status, health.text)
|
|
249
|
+
if (reach) return { reach, status: health.status }
|
|
250
|
+
const manifest = await fetchText(url, 'manifest.webmanifest', timeoutMs)
|
|
251
|
+
if (manifest.cause) return { reach: 'unreachable', cause: manifest.cause }
|
|
252
|
+
const ok = manifest.status >= 200 && manifest.status < 300 && manifest.text.includes('wherdr')
|
|
253
|
+
return { reach: ok ? 'ok' : 'other', status: ok ? manifest.status : health.status }
|
|
254
|
+
}
|
|
255
|
+
|
|
178
256
|
// Runs `tailscale <args>` without a terminal → { code, output }. On a tailnet
|
|
179
257
|
// without HTTPS, `tailscale serve` prints a link and waits: the timeout ends it.
|
|
180
258
|
export function runTailscale(bin, args, timeoutMs = 30_000) {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// One-tap update (Update button of the app, server/utils/selfupdate.ts): runs
|
|
2
|
+
// detached from the server it replaces, so it outlives the restart. Installs
|
|
3
|
+
// the new version, restarts wherdr, waits for /api/health to report the new
|
|
4
|
+
// version, and goes back to the previous one when it does not. Progress goes
|
|
5
|
+
// to <wherdr dir>/update.json (read by the server for the app) and
|
|
6
|
+
// <wherdr dir>/update.log.
|
|
7
|
+
//
|
|
8
|
+
// node updater.mjs <plan.json>
|
|
9
|
+
//
|
|
10
|
+
// The server copies this file out of the package before running it (npm and
|
|
11
|
+
// Homebrew replace the package folder): it imports Node built-ins only.
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
|
|
17
|
+
// Plan (written by the server):
|
|
18
|
+
// kind plugin | npm-global | brew
|
|
19
|
+
// from, to installed and target versions
|
|
20
|
+
// root package folder of the running wherdr (holds bin/ and .output/)
|
|
21
|
+
// dir wherdr folder (~/wherdr): state, log, pid file
|
|
22
|
+
// runtime node (or bun) running the server
|
|
23
|
+
// serverPid pid of the server to replace
|
|
24
|
+
// port, host where it answers
|
|
25
|
+
// registry npm registry (npm_config_registry, default registry.npmjs.org)
|
|
26
|
+
// healthMs how long the new version has to answer (default 60 s)
|
|
27
|
+
|
|
28
|
+
export const HEALTH_MS = 60_000
|
|
29
|
+
|
|
30
|
+
const healthHost = host => !host || host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host.includes(':') ? `[${host}]` : host
|
|
31
|
+
export const healthUrl = plan => `http://${healthHost(plan.host)}:${plan.port}/api/health`
|
|
32
|
+
|
|
33
|
+
// --------------------------------------------------------------- drivers
|
|
34
|
+
// What each install mode does to install, put in place and go back. `io.run`
|
|
35
|
+
// throws on a non-zero exit (the message ends with the command's output).
|
|
36
|
+
|
|
37
|
+
function pluginDriver(plan, io) {
|
|
38
|
+
const staged = `${plan.root}.new`
|
|
39
|
+
const previous = `${plan.root}.prev`
|
|
40
|
+
return {
|
|
41
|
+
// The npm package of the target version, unpacked next to the running copy.
|
|
42
|
+
async install() {
|
|
43
|
+
const tgz = path.join(plan.dir, 'update', `wherdr-${plan.to}.tgz`)
|
|
44
|
+
await io.download(`${plan.registry.replace(/\/$/, '')}/wherdr/-/wherdr-${plan.to}.tgz`, tgz)
|
|
45
|
+
io.rm(staged)
|
|
46
|
+
io.mkdir(staged)
|
|
47
|
+
await io.run('tar', ['-xzf', tgz, '-C', staged, '--strip-components=1'])
|
|
48
|
+
io.rm(tgz)
|
|
49
|
+
if (!io.exists(path.join(staged, '.output', 'server', 'index.mjs'))) throw new Error(`wherdr ${plan.to}: the package has no built server`)
|
|
50
|
+
// The panel's QR code package (the plugin install copies it the same way).
|
|
51
|
+
const qr = path.join(plan.root, 'node_modules', 'qrcode-terminal')
|
|
52
|
+
if (io.exists(qr)) io.copy(qr, path.join(staged, 'node_modules', 'qrcode-terminal'))
|
|
53
|
+
},
|
|
54
|
+
// Server stopped: the previous copy is kept as <root>.prev.
|
|
55
|
+
async activate() {
|
|
56
|
+
io.rm(previous)
|
|
57
|
+
io.rename(plan.root, previous)
|
|
58
|
+
io.rename(staged, plan.root)
|
|
59
|
+
},
|
|
60
|
+
async rollback() {
|
|
61
|
+
if (!io.exists(previous)) throw new Error(`no previous copy to go back to (${previous})`)
|
|
62
|
+
io.rm(plan.root)
|
|
63
|
+
io.rename(previous, plan.root)
|
|
64
|
+
},
|
|
65
|
+
start: () => cliStart(plan, io),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function npmDriver(plan, io) {
|
|
70
|
+
const local = path.join(path.dirname(plan.runtime), 'npm')
|
|
71
|
+
const npm = io.exists(local) ? local : 'npm'
|
|
72
|
+
const install = version => io.run(npm, ['install', '--global', `wherdr@${version}`, '--no-audit', '--no-fund'])
|
|
73
|
+
return {
|
|
74
|
+
install: () => install(plan.to),
|
|
75
|
+
async activate() {},
|
|
76
|
+
rollback: () => install(plan.from),
|
|
77
|
+
start: () => cliStart(plan, io),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// <prefix>/Cellar/wherdr/<version>/libexec/lib/node_modules/wherdr (the
|
|
82
|
+
// formula's npm install) → <prefix> and the keg <prefix>/Cellar/wherdr/<version>.
|
|
83
|
+
function brewDriver(plan, io) {
|
|
84
|
+
const m = /^(.*)[\\/]Cellar[\\/]wherdr[\\/][^\\/]+/.exec(plan.root)
|
|
85
|
+
if (!m) throw new Error(`not a Homebrew keg: ${plan.root}`)
|
|
86
|
+
const keg = m[0]
|
|
87
|
+
const prefix = m[1]
|
|
88
|
+
const brew = path.join(prefix, 'bin', 'brew')
|
|
89
|
+
const opt = path.join(prefix, 'opt', 'wherdr')
|
|
90
|
+
// The old keg stays in the Cellar for the way back.
|
|
91
|
+
const env = { HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_ENV_HINTS: '1' }
|
|
92
|
+
const optVersion = () => path.basename(io.realpath(opt))
|
|
93
|
+
return {
|
|
94
|
+
async install() {
|
|
95
|
+
await io.run(brew, ['upgrade', 'wherdr'], env)
|
|
96
|
+
const now = optVersion()
|
|
97
|
+
if (now !== plan.to && !now.startsWith(`${plan.to}_`)) throw new Error(`Homebrew installed ${now}, not ${plan.to}: its formula may not offer ${plan.to} yet`)
|
|
98
|
+
},
|
|
99
|
+
async activate() {},
|
|
100
|
+
// `brew link` (and opt/, which brew services runs) follows the newest keg:
|
|
101
|
+
// the keg that does not start is removed, then the previous one is linked.
|
|
102
|
+
// Homebrew then sees the previous version installed, and the update offered again.
|
|
103
|
+
async rollback() {
|
|
104
|
+
if (!io.exists(keg)) throw new Error(`the previous Homebrew version (${keg}) was removed: reinstall it with brew install wherdr`)
|
|
105
|
+
const failed = io.realpath(opt)
|
|
106
|
+
await io.run(brew, ['unlink', 'wherdr'], env)
|
|
107
|
+
if (failed !== keg && path.dirname(failed) === path.dirname(keg)) io.rm(failed)
|
|
108
|
+
await io.run(brew, ['link', '--overwrite', 'wherdr'], env)
|
|
109
|
+
},
|
|
110
|
+
start: () => io.run(brew, ['services', 'restart', 'wherdr'], env),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `wherdr start` of the package now in place: the pid file, or the login service.
|
|
115
|
+
function cliStart(plan, io) {
|
|
116
|
+
return io.run(plan.runtime, [path.join(plan.root, 'bin', 'wherdr.mjs'), 'start'])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function driverFor(plan, io) {
|
|
120
|
+
if (plan.kind === 'plugin') return pluginDriver(plan, io)
|
|
121
|
+
if (plan.kind === 'npm-global') return npmDriver(plan, io)
|
|
122
|
+
if (plan.kind === 'brew') return brewDriver(plan, io)
|
|
123
|
+
throw new Error(`no one-tap update for ${plan.kind}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------- state machine
|
|
127
|
+
// installing → restarting → checking → done; a failed install leaves the old
|
|
128
|
+
// server running (failed); a new version that does not start or answer goes
|
|
129
|
+
// back: rolling-back → rolled-back (or failed when the old one does not come back).
|
|
130
|
+
export async function runUpdate(plan, io) {
|
|
131
|
+
const d = driverFor(plan, io)
|
|
132
|
+
const healthMs = plan.healthMs ?? HEALTH_MS
|
|
133
|
+
io.log(`update ${plan.from} → ${plan.to} (${plan.kind}, ${plan.root})`)
|
|
134
|
+
io.state('installing')
|
|
135
|
+
try { await d.install() } catch (e) {
|
|
136
|
+
io.log(`install failed: ${e.message}`)
|
|
137
|
+
io.state('failed', `The update could not be installed: ${e.message}. Still on ${plan.from}.`)
|
|
138
|
+
return 'failed'
|
|
139
|
+
}
|
|
140
|
+
io.state('restarting')
|
|
141
|
+
let reason = ''
|
|
142
|
+
try {
|
|
143
|
+
await io.stopServer()
|
|
144
|
+
await d.activate()
|
|
145
|
+
await d.start()
|
|
146
|
+
io.state('checking')
|
|
147
|
+
if (await io.healthy(plan.to, healthMs)) {
|
|
148
|
+
io.log(`wherdr ${plan.to} answers: done`)
|
|
149
|
+
io.state('done')
|
|
150
|
+
return 'done'
|
|
151
|
+
}
|
|
152
|
+
reason = `wherdr ${plan.to} did not answer within ${Math.round(healthMs / 1000)} s`
|
|
153
|
+
} catch (e) { reason = e.message }
|
|
154
|
+
io.log(`${reason}: going back to ${plan.from}`)
|
|
155
|
+
io.state('rolling-back', reason)
|
|
156
|
+
try {
|
|
157
|
+
await io.stopServer()
|
|
158
|
+
await d.rollback()
|
|
159
|
+
await d.start()
|
|
160
|
+
if (await io.healthy(plan.from, healthMs)) {
|
|
161
|
+
io.log(`wherdr ${plan.from} answers again: rolled back`)
|
|
162
|
+
io.state('rolled-back', `wherdr ${plan.to} did not start (${reason}). Rolled back to ${plan.from}.`)
|
|
163
|
+
return 'rolled-back'
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`wherdr ${plan.from} did not answer either`)
|
|
166
|
+
} catch (e) {
|
|
167
|
+
io.log(`rollback failed: ${e.message}`)
|
|
168
|
+
io.state('failed', `wherdr ${plan.to} did not start (${reason}), and going back to ${plan.from} failed: ${e.message}. See ${path.join(plan.dir, 'update.log')}.`)
|
|
169
|
+
return 'failed'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ------------------------------------------------------------- real I/O
|
|
174
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
175
|
+
|
|
176
|
+
function alive(pid) {
|
|
177
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function realIo(plan, now = Date.now) {
|
|
181
|
+
const stateFile = path.join(plan.dir, 'update.json')
|
|
182
|
+
const logFile = path.join(plan.dir, 'update.log')
|
|
183
|
+
const startedAt = now()
|
|
184
|
+
const log = (line) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`) } catch {} }
|
|
185
|
+
// The commands see the runtime first on the PATH (npm's `#!/usr/bin/env node`).
|
|
186
|
+
const PATH = `${path.dirname(plan.runtime)}${path.delimiter}${process.env.PATH || ''}`
|
|
187
|
+
return {
|
|
188
|
+
log,
|
|
189
|
+
state(state, message) {
|
|
190
|
+
const job = { state, from: plan.from, to: plan.to, mode: plan.kind, startedAt, updatedAt: now(), ...(message ? { message } : {}) }
|
|
191
|
+
writeFileSync(`${stateFile}.tmp`, `${JSON.stringify(job)}\n`)
|
|
192
|
+
renameSync(`${stateFile}.tmp`, stateFile)
|
|
193
|
+
},
|
|
194
|
+
run(cmd, args, env = {}) {
|
|
195
|
+
log(`$ ${[cmd, ...args].join(' ')}`)
|
|
196
|
+
const { promise, resolve, reject } = Promise.withResolvers()
|
|
197
|
+
const child = spawn(cmd, args, { cwd: plan.dir, env: { ...process.env, PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
198
|
+
let out = ''
|
|
199
|
+
const keep = (b) => { out = (out + b).slice(-4000); try { appendFileSync(logFile, b) } catch {} }
|
|
200
|
+
child.stdout.on('data', keep)
|
|
201
|
+
child.stderr.on('data', keep)
|
|
202
|
+
child.on('error', e => reject(new Error(`${path.basename(cmd)}: ${e.message}`)))
|
|
203
|
+
child.on('close', (code) => {
|
|
204
|
+
if (code === 0) return resolve(out)
|
|
205
|
+
// The lines that say what went wrong (`wherdr: …`, `Error: …`), not a stack trace.
|
|
206
|
+
const lines = out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
207
|
+
const why = lines.filter(l => /^(wherdr: |error\b|\w*Error: )/i.test(l)).slice(0, 2)
|
|
208
|
+
const tail = (why.length ? why : lines.slice(-1)).join(' / ')
|
|
209
|
+
reject(new Error(`${path.basename(cmd)} ${args[0]} exited with ${code}${tail ? `: ${tail}` : ''}`))
|
|
210
|
+
})
|
|
211
|
+
return promise
|
|
212
|
+
},
|
|
213
|
+
async download(url, file) {
|
|
214
|
+
log(`GET ${url}`)
|
|
215
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(180_000) })
|
|
216
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`)
|
|
217
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
218
|
+
writeFileSync(file, Buffer.from(await res.arrayBuffer()))
|
|
219
|
+
},
|
|
220
|
+
// The server being replaced, then whatever `wherdr start` left (pid file).
|
|
221
|
+
async stopServer() {
|
|
222
|
+
const pids = [plan.serverPid]
|
|
223
|
+
try { pids.push(Number(readFileSync(path.join(plan.dir, 'wherdr.pid'), 'utf8').trim())) } catch {}
|
|
224
|
+
for (const pid of new Set(pids.filter(p => p && p !== process.pid && alive(p)))) {
|
|
225
|
+
log(`stopping pid ${pid}`)
|
|
226
|
+
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
227
|
+
for (let i = 0; i < 40 && alive(pid); i++) await sleep(250)
|
|
228
|
+
if (alive(pid)) try { process.kill(pid, 'SIGKILL') } catch {}
|
|
229
|
+
}
|
|
230
|
+
rmSync(path.join(plan.dir, 'wherdr.pid'), { force: true })
|
|
231
|
+
},
|
|
232
|
+
async healthy(version, ms) {
|
|
233
|
+
const until = now() + ms
|
|
234
|
+
const get = async (url) => {
|
|
235
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
|
|
236
|
+
return { status: res.status, body: res.ok ? await res.json().catch(() => null) : null }
|
|
237
|
+
}
|
|
238
|
+
while (now() < until) {
|
|
239
|
+
try {
|
|
240
|
+
const health = await get(healthUrl(plan))
|
|
241
|
+
if (health.body?.version === version) return true
|
|
242
|
+
// Versions up to 1.3.1 have no /api/health: their version is in
|
|
243
|
+
// /api/update (unless the app is locked).
|
|
244
|
+
if (health.body?.version === undefined && (await get(healthUrl(plan).replace(/health$/, 'update'))).body?.current === version) return true
|
|
245
|
+
} catch {}
|
|
246
|
+
await sleep(1000)
|
|
247
|
+
}
|
|
248
|
+
return false
|
|
249
|
+
},
|
|
250
|
+
exists: p => existsSync(p),
|
|
251
|
+
rm: p => rmSync(p, { recursive: true, force: true }),
|
|
252
|
+
mkdir: p => mkdirSync(p, { recursive: true }),
|
|
253
|
+
rename: (a, b) => renameSync(a, b),
|
|
254
|
+
copy: (a, b) => cpSync(a, b, { recursive: true }),
|
|
255
|
+
realpath: p => realpathSync(p),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
260
|
+
const plan = JSON.parse(readFileSync(process.argv[2], 'utf8'))
|
|
261
|
+
const io = realIo(plan)
|
|
262
|
+
runUpdate(plan, io).then(r => process.exit(r === 'done' ? 0 : 1), (e) => {
|
|
263
|
+
io.log(`updater crashed: ${e?.stack || e}`)
|
|
264
|
+
try { io.state('failed', `The updater crashed: ${e?.message || e}`) } catch {}
|
|
265
|
+
process.exit(1)
|
|
266
|
+
})
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wherdr",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Unofficial mobile web client (PWA) to drive the coding agents running in Herdr",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"dev": "nuxt dev",
|
|
35
35
|
"build": "nuxt build",
|
|
36
|
+
"build:demo": "VITE_WHERDR_DEMO=1 nuxt generate",
|
|
36
37
|
"start": "HOST=${HOST:-127.0.0.1} PORT=${PORT:-7683} node .output/server/index.mjs",
|
|
37
38
|
"typecheck": "nuxt typecheck",
|
|
38
39
|
"test": "vitest run",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
var e=Object.defineProperty,t=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),n=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:`Module`}),r};export{n,t};
|