@haifai/bwhale 0.1.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/README.md +53 -0
- package/bin/bwhale.js +227 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# bwhale — Baby Whale in one command
|
|
2
|
+
|
|
3
|
+
Local-first knowledge-work coworker: give it a task, get back finished
|
|
4
|
+
Excel workbooks, PowerPoint decks, Word documents, and PDFs — built by
|
|
5
|
+
code, previewed pixel-perfect, and never leaving your machine.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g bwhale
|
|
9
|
+
bwhale
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
First run fetches the runtime once (~400 MB, from this project's GitHub
|
|
13
|
+
Releases) into `~/.bwhale`, checks your platform's prerequisites, and
|
|
14
|
+
opens `http://127.0.0.1:24680`. Later runs start instantly.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
| Command | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `bwhale` | Start (first run installs the runtime) |
|
|
21
|
+
| `bwhale doctor` | Report what's present / missing on your machine |
|
|
22
|
+
| `bwhale update` | Refresh to the latest release on next start |
|
|
23
|
+
|
|
24
|
+
## What it checks and installs
|
|
25
|
+
|
|
26
|
+
The app ships with its own Node — you only need the platform basics, and
|
|
27
|
+
`bwhale doctor` tells you exactly what's missing and the platform-native
|
|
28
|
+
command to get it:
|
|
29
|
+
|
|
30
|
+
- **macOS** — nothing else required; office libraries install themselves
|
|
31
|
+
on first boot
|
|
32
|
+
- **Linux** — `git` required; `python3` for office-file creation;
|
|
33
|
+
`libreoffice` optional (pixel-perfect previews, also installable
|
|
34
|
+
in-app later)
|
|
35
|
+
- **Windows** — `git`; `python3` for office creation
|
|
36
|
+
|
|
37
|
+
Connect a model (one-time): create `~/.dsh/.credentials.yaml`:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
version: 1
|
|
41
|
+
refs:
|
|
42
|
+
DEEPSEEK_API_KEY: sk-your-key-here
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
then `chmod 600 ~/.dsh/.credentials.yaml`.
|
|
46
|
+
|
|
47
|
+
## Privacy
|
|
48
|
+
|
|
49
|
+
Files, sessions, history, and the workspace live entirely on your machine.
|
|
50
|
+
The only network calls are the model provider you configure, the one-time
|
|
51
|
+
runtime download, and update checks.
|
|
52
|
+
|
|
53
|
+
MIT — a knowledge-work product built on the DeepSeek Harness.
|
package/bin/bwhale.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bwhale — the npm launcher for Baby Whale. The published package carries
|
|
4
|
+
* only the app code pointer and this installer/launcher (never
|
|
5
|
+
* node_modules); the runtime bundle is fetched once from GitHub Releases
|
|
6
|
+
* into ~/.bwhale, with per-platform prerequisites checked — and offered for
|
|
7
|
+
* install the platform's own way — before boot.
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* bwhale start (first run: fetch bundle, then boot + open)
|
|
11
|
+
* bwhale doctor report prerequisite status per platform convention
|
|
12
|
+
* bwhale update refresh the runtime bundle to the latest release
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { homedir, platform, arch } from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { Readable } from 'node:stream'
|
|
19
|
+
import { finished } from 'node:stream/promises'
|
|
20
|
+
|
|
21
|
+
const REPO = 'Haifai-AI/baby-whale'
|
|
22
|
+
const HOME = homedir()
|
|
23
|
+
const ROOT = process.env.BWHALE_HOME ?? path.join(HOME, '.bwhale')
|
|
24
|
+
const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`
|
|
25
|
+
const PORT = 24680
|
|
26
|
+
const MARKER_VERSION = 2 // bundle layout revision; bump forces a refetch
|
|
27
|
+
|
|
28
|
+
const SUPPORT = {
|
|
29
|
+
darwin: {
|
|
30
|
+
label: 'macOS',
|
|
31
|
+
platformKey: () => 'macos',
|
|
32
|
+
missing: () => [], // git/python ship with the dev tools; office libs auto-install at first boot
|
|
33
|
+
},
|
|
34
|
+
linux: {
|
|
35
|
+
label: 'Linux',
|
|
36
|
+
platformKey: () => 'linux',
|
|
37
|
+
missing: () => {
|
|
38
|
+
const gaps = []
|
|
39
|
+
if (!which('git')) gaps.push({ name: 'git', hint: 'sudo apt install git (or your distro equivalent)' })
|
|
40
|
+
if (!which('python3')) gaps.push({ name: 'python3', hint: 'sudo apt install python3 python3-venv (office-file creation)' })
|
|
41
|
+
if (!which('soffice')) gaps.push({
|
|
42
|
+
name: 'LibreOffice (optional — pixel-perfect previews)',
|
|
43
|
+
hint: 'sudo apt install libreoffice — or accept the in-app one-time setup later',
|
|
44
|
+
})
|
|
45
|
+
return gaps
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
win32: {
|
|
49
|
+
label: 'Windows',
|
|
50
|
+
platformKey: () => 'windows',
|
|
51
|
+
missing: () => {
|
|
52
|
+
const gaps = []
|
|
53
|
+
if (!which('git')) gaps.push({ name: 'git', hint: 'winget install Git.Git' })
|
|
54
|
+
if (!which('python')) gaps.push({ name: 'python3', hint: 'winget install Python.Python.3.12 (office-file creation)' })
|
|
55
|
+
return gaps
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}[platform()] ?? null
|
|
59
|
+
|
|
60
|
+
function which(bin) {
|
|
61
|
+
const probe = platform() === 'win32'
|
|
62
|
+
? spawnSync('where', [bin], { stdio: 'ignore' })
|
|
63
|
+
: spawnSync('which', [bin], { stdio: 'ignore' })
|
|
64
|
+
return probe.status === 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function log(message) { console.error(message) }
|
|
68
|
+
function die(message) { log(`bwhale: ${message}`); process.exit(1) }
|
|
69
|
+
|
|
70
|
+
/** The platform/arch bundle asset this machine needs. */
|
|
71
|
+
function bundleAssetName(version) {
|
|
72
|
+
const p = SUPPORT?.platformKey() ?? platform()
|
|
73
|
+
const a = arch() === 'arm64' ? 'arm64' : 'x86_64'
|
|
74
|
+
return `baby-whale-${p}-${a}-${version}.zip`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function latestRelease() {
|
|
78
|
+
const response = await fetch(LATEST_API, { headers: { 'user-agent': 'bwhale-launcher' } })
|
|
79
|
+
if (!response.ok) die(`cannot reach GitHub Releases (HTTP ${response.status})`)
|
|
80
|
+
const release = await response.json()
|
|
81
|
+
const asset = (release.assets ?? []).find(a => a.name === bundleAssetName(release.tag_name))
|
|
82
|
+
if (asset === undefined) {
|
|
83
|
+
die(`no bundle for ${platform()}/${arch()} in release ${release.tag_name} — assets: ${(release.assets ?? []).map(a => a.name).join(', ') || 'none'}`)
|
|
84
|
+
}
|
|
85
|
+
return { version: release.tag_name, url: asset.browser_download_url, name: asset.name, size: asset.size }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stream a download with a percent progress line; resumable cache across runs. */
|
|
89
|
+
async function download(url, dest, size) {
|
|
90
|
+
mkdirSync(path.dirname(dest), { recursive: true })
|
|
91
|
+
const partial = `${dest}.part`
|
|
92
|
+
const response = await fetch(url, { redirect: 'follow' })
|
|
93
|
+
if (!response.ok || response.body === null) die(`download failed with HTTP ${response.status}`)
|
|
94
|
+
const total = Number(response.headers.get('content-length') ?? size ?? 0)
|
|
95
|
+
let received = 0
|
|
96
|
+
const out = createWriteStream(partial)
|
|
97
|
+
process.stderr.write('bwhale: downloading runtime ')
|
|
98
|
+
for await (const chunk of Readable.fromWeb(response.body)) {
|
|
99
|
+
received += chunk.length
|
|
100
|
+
out.write(chunk)
|
|
101
|
+
if (total > 0) process.stderr.write(`\rbwhale: downloading runtime ${Math.round((received / total) * 100)}%`)
|
|
102
|
+
}
|
|
103
|
+
process.stderr.write('\n')
|
|
104
|
+
await finished(out)
|
|
105
|
+
// integrity: the release asset is the contract; a truncated file must never install
|
|
106
|
+
if (total > 0 && received !== total) die(`download truncated (${received}/${total} bytes) — retry`)
|
|
107
|
+
rmSync(dest, { force: true })
|
|
108
|
+
statSync(partial)
|
|
109
|
+
return partial
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function unzip(archive, into) {
|
|
113
|
+
mkdirSync(into, { recursive: true })
|
|
114
|
+
const result = platform() === 'win32'
|
|
115
|
+
? spawnSync('powershell', ['-NoProfile', '-Command', `Expand-Archive -Force -LiteralPath "${archive}" -DestinationPath "${into}"`], { stdio: 'inherit' })
|
|
116
|
+
: spawnSync('unzip', ['-q', archive, '-d', into], { stdio: 'inherit' })
|
|
117
|
+
if (result.status !== 0) die('extraction failed')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Installed bundle layout: <ROOT>/runtime/baby-whale-<p>-<a>-<version> */
|
|
121
|
+
function runtimeDir(version) {
|
|
122
|
+
const p = SUPPORT?.platformKey() ?? platform()
|
|
123
|
+
const a = arch() === 'arm64' ? 'arm64' : 'x86_64'
|
|
124
|
+
return path.join(ROOT, 'runtime', `baby-whale-${p}-${a}-${version}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function installedMarker() {
|
|
128
|
+
return path.join(ROOT, 'installed.json')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readInstalled() {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(readFileSync(installedMarker(), 'utf8'))
|
|
134
|
+
return parsed?.markerVersion === MARKER_VERSION ? parsed : null
|
|
135
|
+
} catch { return null }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeInstalled(entry) {
|
|
139
|
+
mkdirSync(ROOT, { recursive: true })
|
|
140
|
+
writeFileSync(installedMarker(), JSON.stringify({ markerVersion: MARKER_VERSION, ...entry }, null, 2))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Ensure the runtime bundle exists at the wanted version; fetch+install if not. */
|
|
144
|
+
async function ensureRuntime(wanted) {
|
|
145
|
+
const existing = readInstalled()
|
|
146
|
+
if (wanted === 'installed' && existing !== null) return existing
|
|
147
|
+
const release = wanted === 'latest'
|
|
148
|
+
? await latestRelease()
|
|
149
|
+
: { version: wanted, url: `https://github.com/${REPO}/releases/download/${wanted}/${bundleAssetName(wanted)}`, name: bundleAssetName(wanted) }
|
|
150
|
+
if (existing?.version === release.version) return existing // already exactly this version
|
|
151
|
+
log(`bwhale: fetching Baby Whale ${release.version} runtime (${Math.round((release.size ?? 0) / 1048576) || '~400'} MB, once)`)
|
|
152
|
+
const archive = await download(release.url, path.join(ROOT, 'cache', release.name), release.size)
|
|
153
|
+
const dir = runtimeDir(release.version)
|
|
154
|
+
rmSync(dir, { recursive: true, force: true })
|
|
155
|
+
unzip(archive, path.join(ROOT, 'runtime'))
|
|
156
|
+
const inner = path.join(dir, 'baby-whale')
|
|
157
|
+
if (!existsSync(inner)) die('bundle layout unexpected — no baby-whale directory')
|
|
158
|
+
if (platform() !== 'win32') {
|
|
159
|
+
// launcher + node binary need the exec bit back (zip loses some)
|
|
160
|
+
for (const rel of ['START.command', 'START', 'node/bin/node']) {
|
|
161
|
+
const p = path.join(dir, rel)
|
|
162
|
+
if (existsSync(p)) spawnSync('chmod', ['+x', p])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
rmSync(archive, { force: true })
|
|
166
|
+
const entry = { version: release.version, dir, installedAt: new Date().toISOString() }
|
|
167
|
+
writeInstalled(entry)
|
|
168
|
+
return entry
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdStart(argv) {
|
|
172
|
+
const gaps = SUPPORT?.missing() ?? []
|
|
173
|
+
if (gaps.length > 0) {
|
|
174
|
+
log(`bwhale: missing prerequisites on ${SUPPORT?.label ?? platform()}:`)
|
|
175
|
+
for (const gap of gaps) log(` - ${gap.name}: ${gap.hint}`)
|
|
176
|
+
if (gaps.some(g => g.name.startsWith('git'))) die('git is required (session history)')
|
|
177
|
+
}
|
|
178
|
+
const entry = await ensureRuntime(process.env.BWHALE_VERSION ?? 'latest')
|
|
179
|
+
const nodeBin = platform() === 'win32'
|
|
180
|
+
? path.join(entry.dir, 'node', 'node.exe')
|
|
181
|
+
: path.join(entry.dir, 'node', 'bin', 'node')
|
|
182
|
+
const cli = path.join(entry.dir, 'baby-whale', 'apps', 'cli', 'lib', 'bin.js')
|
|
183
|
+
if (!existsSync(cli)) die(`runtime incomplete: ${cli} missing — run \`bwhale update\``)
|
|
184
|
+
// Already serving? Never double-start — just bring the workspace up.
|
|
185
|
+
try {
|
|
186
|
+
const probe = await fetch(`http://127.0.0.1:${PORT}/`, { signal: AbortSignal.timeout(1500) })
|
|
187
|
+
if (probe.ok) {
|
|
188
|
+
log(`bwhale: Baby Whale is already running at http://127.0.0.1:${PORT}/`)
|
|
189
|
+
const open = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open'
|
|
190
|
+
spawnSync(open, [`http://127.0.0.1:${PORT}/`], { stdio: 'ignore' })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Not running — boot it below.
|
|
195
|
+
}
|
|
196
|
+
const child = spawnSync(nodeBin, [cli, 'web', ...argv], { stdio: 'inherit' })
|
|
197
|
+
process.exitCode = child.status ?? 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function cmdDoctor() {
|
|
201
|
+
const p = platform()
|
|
202
|
+
log(`bwhale doctor — ${SUPPORT?.label ?? p} (${arch()})`)
|
|
203
|
+
log(` node: ${process.version} (launcher runtime — the app ships its own)`)
|
|
204
|
+
log(` git: ${which('git') ? 'ok' : 'MISSING (required)'}`)
|
|
205
|
+
log(` python3: ${which('python3') || which('python') ? 'ok' : 'MISSING (office-file creation auto-installs libs on first boot)'}`)
|
|
206
|
+
log(` libreoffice: ${which('soffice') ? 'ok' : 'not found (optional — the app offers a one-time setup for pixel-perfect previews)'}`)
|
|
207
|
+
const entry = readInstalled()
|
|
208
|
+
log(` runtime: ${entry === null ? 'not installed yet (first \`bwhale\` fetches it)' : `${entry.version} at ${entry.dir}`}`)
|
|
209
|
+
const gaps = SUPPORT?.missing() ?? []
|
|
210
|
+
if (gaps.length > 0) {
|
|
211
|
+
log(' to install missing pieces:')
|
|
212
|
+
for (const gap of gaps) log(` ${gap.name}: ${gap.hint}`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cmdUpdate() {
|
|
217
|
+
rmSync(installedMarker(), { force: true })
|
|
218
|
+
log('bwhale: will fetch the latest release on next start.')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const [command = 'start', ...rest] = process.argv.slice(2)
|
|
222
|
+
const dispatch = {
|
|
223
|
+
start: () => cmdStart(rest.filter(a => a !== '--no-open')),
|
|
224
|
+
doctor: () => cmdDoctor(),
|
|
225
|
+
update: () => cmdUpdate(),
|
|
226
|
+
}
|
|
227
|
+
;(dispatch[command] ?? die(`unknown command "${command}" — try bwhale, bwhale doctor, or bwhale update`))()
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@haifai/bwhale",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Baby Whale — local-first knowledge-work coworker. One command: bwhale. Fetches its runtime once, starts the app, opens your browser. Nothing leaves your machine.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": { "access": "public" },
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": { "bwhale": "bin/bwhale.js" },
|
|
9
|
+
"files": ["bin/", "README.md"],
|
|
10
|
+
"engines": { "node": ">=20" },
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Haifai-AI/baby-whale.git",
|
|
14
|
+
"directory": "npm/bwhale"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["ai", "agent", "office", "xlsx", "pptx", "docx", "cowork", "local-first"],
|
|
17
|
+
"scripts": { "prepack": "node --check bin/bwhale.js" }
|
|
18
|
+
}
|