quilltap 3.0.0-dev.79
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 +109 -0
- package/bin/quilltap.js +219 -0
- package/lib/download-manager.js +292 -0
- package/lib/tar-extract.js +226 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Quilltap
|
|
2
|
+
|
|
3
|
+
**Self-hosted AI workspace for writers, worldbuilders, roleplayers, and anyone who wants an AI assistant that actually knows what they're working on.**
|
|
4
|
+
|
|
5
|
+
Run Quilltap as a local Node.js server with zero configuration.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g quilltap
|
|
11
|
+
quilltap
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then open [http://localhost:3000](http://localhost:3000).
|
|
15
|
+
|
|
16
|
+
On first run, the CLI downloads the application files (~150-250 MB compressed) and caches them locally. Subsequent launches start instantly.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
### Install globally (recommended)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g quilltap
|
|
24
|
+
quilltap
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Run directly (no install)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx quilltap
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
quilltap [options]
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-p, --port <number> Port to listen on (default: 3000)
|
|
40
|
+
-d, --data-dir <path> Data directory (default: platform-specific)
|
|
41
|
+
-o, --open Open browser after server starts
|
|
42
|
+
-v, --version Show version number
|
|
43
|
+
--update Force re-download of application files
|
|
44
|
+
-h, --help Show this help message
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Examples
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Start on default port 3000
|
|
51
|
+
quilltap
|
|
52
|
+
|
|
53
|
+
# Start on a custom port
|
|
54
|
+
quilltap --port 8080
|
|
55
|
+
|
|
56
|
+
# Use a custom data directory
|
|
57
|
+
quilltap --data-dir /mnt/data/quilltap
|
|
58
|
+
|
|
59
|
+
# Start and open browser automatically
|
|
60
|
+
quilltap --open
|
|
61
|
+
|
|
62
|
+
# Force re-download after a manual update
|
|
63
|
+
quilltap --update
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## How It Works
|
|
67
|
+
|
|
68
|
+
The `quilltap` npm package is a lightweight CLI launcher (~10 KB). On first run, it downloads the pre-built application from [GitHub Releases](https://github.com/foundry-9/quilltap/releases) and caches it in a platform-specific directory. Native modules (`better-sqlite3`, `sharp`) are compiled for your platform when you install the npm package.
|
|
69
|
+
|
|
70
|
+
### Cache Locations
|
|
71
|
+
|
|
72
|
+
| Platform | Cache Directory |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| macOS | `~/Library/Caches/Quilltap/standalone/` |
|
|
75
|
+
| Linux | `~/.cache/quilltap/standalone/` |
|
|
76
|
+
| Windows | `%LOCALAPPDATA%\Quilltap\standalone\` |
|
|
77
|
+
|
|
78
|
+
When you upgrade to a new version (`npm update -g quilltap`), the next run detects the version mismatch and downloads the matching application files automatically.
|
|
79
|
+
|
|
80
|
+
## Data Directory
|
|
81
|
+
|
|
82
|
+
Quilltap stores its database, files, and logs in a platform-specific directory:
|
|
83
|
+
|
|
84
|
+
| Platform | Default Location |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| macOS | `~/Library/Application Support/Quilltap` |
|
|
87
|
+
| Linux | `~/.quilltap` |
|
|
88
|
+
| Windows | `%APPDATA%\Quilltap` |
|
|
89
|
+
|
|
90
|
+
Override with `--data-dir` or the `QUILLTAP_DATA_DIR` environment variable.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- Node.js 18 or later
|
|
95
|
+
|
|
96
|
+
## Other Ways to Run Quilltap
|
|
97
|
+
|
|
98
|
+
- **Electron desktop app** (macOS, Windows) - [Download](https://github.com/foundry-9/quilltap/releases)
|
|
99
|
+
- **Docker** - `docker run -d -p 3000:3000 -v /path/to/data:/app/quilltap csebold/quilltap`
|
|
100
|
+
|
|
101
|
+
## Links
|
|
102
|
+
|
|
103
|
+
- **Website:** [quilltap.ai](https://quilltap.ai)
|
|
104
|
+
- **GitHub:** [github.com/foundry-9/quilltap](https://github.com/foundry-9/quilltap)
|
|
105
|
+
- **Issues:** [github.com/foundry-9/quilltap/issues](https://github.com/foundry-9/quilltap/issues)
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
package/bin/quilltap.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { fork, exec } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { getCacheDir, isCacheValid, ensureStandalone } = require('../lib/download-manager');
|
|
8
|
+
|
|
9
|
+
const PACKAGE_DIR = path.resolve(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
// Read version from package.json
|
|
12
|
+
function getVersion() {
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf-8'));
|
|
14
|
+
return pkg.version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Parse CLI arguments
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const opts = {
|
|
20
|
+
port: 3000,
|
|
21
|
+
dataDir: '',
|
|
22
|
+
open: false,
|
|
23
|
+
help: false,
|
|
24
|
+
version: false,
|
|
25
|
+
update: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const args = argv.slice(2);
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < args.length) {
|
|
31
|
+
switch (args[i]) {
|
|
32
|
+
case '--port':
|
|
33
|
+
case '-p':
|
|
34
|
+
opts.port = parseInt(args[++i], 10);
|
|
35
|
+
if (isNaN(opts.port) || opts.port < 1 || opts.port > 65535) {
|
|
36
|
+
console.error('Error: --port must be a number between 1 and 65535');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
case '--data-dir':
|
|
41
|
+
case '-d':
|
|
42
|
+
opts.dataDir = args[++i];
|
|
43
|
+
break;
|
|
44
|
+
case '--open':
|
|
45
|
+
case '-o':
|
|
46
|
+
opts.open = true;
|
|
47
|
+
break;
|
|
48
|
+
case '--version':
|
|
49
|
+
case '-v':
|
|
50
|
+
opts.version = true;
|
|
51
|
+
break;
|
|
52
|
+
case '--update':
|
|
53
|
+
opts.update = true;
|
|
54
|
+
break;
|
|
55
|
+
case '--help':
|
|
56
|
+
case '-h':
|
|
57
|
+
opts.help = true;
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
console.error(`Unknown argument: ${args[i]}`);
|
|
61
|
+
console.error('Run "quilltap --help" for usage information.');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return opts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printHelp() {
|
|
71
|
+
console.log(`
|
|
72
|
+
Quilltap - Self-hosted AI workspace
|
|
73
|
+
|
|
74
|
+
Usage: quilltap [options]
|
|
75
|
+
|
|
76
|
+
Options:
|
|
77
|
+
-p, --port <number> Port to listen on (default: 3000)
|
|
78
|
+
-d, --data-dir <path> Data directory (default: platform-specific)
|
|
79
|
+
-o, --open Open browser after server starts
|
|
80
|
+
-v, --version Show version number
|
|
81
|
+
--update Force re-download of application files
|
|
82
|
+
-h, --help Show this help message
|
|
83
|
+
|
|
84
|
+
Data directory defaults:
|
|
85
|
+
macOS: ~/Library/Application Support/Quilltap
|
|
86
|
+
Linux: ~/.quilltap
|
|
87
|
+
Windows: %APPDATA%\\Quilltap
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
quilltap # Start on port 3000
|
|
91
|
+
quilltap -p 8080 # Start on port 8080
|
|
92
|
+
quilltap -d /mnt/data/quilltap # Custom data directory
|
|
93
|
+
quilltap -o # Start and open browser
|
|
94
|
+
quilltap --update # Re-download app files
|
|
95
|
+
|
|
96
|
+
More info: https://quilltap.ai
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function openBrowser(url) {
|
|
101
|
+
const platform = process.platform;
|
|
102
|
+
let cmd;
|
|
103
|
+
if (platform === 'darwin') {
|
|
104
|
+
cmd = `open "${url}"`;
|
|
105
|
+
} else if (platform === 'win32') {
|
|
106
|
+
cmd = `start "" "${url}"`;
|
|
107
|
+
} else {
|
|
108
|
+
cmd = `xdg-open "${url}"`;
|
|
109
|
+
}
|
|
110
|
+
exec(cmd, (err) => {
|
|
111
|
+
if (err) {
|
|
112
|
+
console.log(`Could not open browser automatically. Visit: ${url}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Main
|
|
118
|
+
async function main() {
|
|
119
|
+
const opts = parseArgs(process.argv);
|
|
120
|
+
|
|
121
|
+
if (opts.help) {
|
|
122
|
+
printHelp();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const version = getVersion();
|
|
127
|
+
|
|
128
|
+
if (opts.version) {
|
|
129
|
+
console.log(version);
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Ensure standalone files are downloaded and cached
|
|
134
|
+
const cacheDir = getCacheDir();
|
|
135
|
+
let standaloneDir;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
standaloneDir = await ensureStandalone(version, cacheDir, { force: opts.update });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error('');
|
|
141
|
+
console.error(err.message);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const serverJs = path.join(standaloneDir, 'server.js');
|
|
146
|
+
|
|
147
|
+
if (!fs.existsSync(serverJs)) {
|
|
148
|
+
console.error('Error: server.js not found in cached standalone directory.');
|
|
149
|
+
console.error('Try running "quilltap --update" to re-download.');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Set up environment
|
|
154
|
+
const env = {
|
|
155
|
+
...process.env,
|
|
156
|
+
NODE_ENV: 'production',
|
|
157
|
+
PORT: String(opts.port),
|
|
158
|
+
HOSTNAME: '0.0.0.0',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (opts.dataDir) {
|
|
162
|
+
env.QUILLTAP_DATA_DIR = path.resolve(opts.dataDir);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Set NODE_PATH so native modules resolve from the npm package's own node_modules.
|
|
166
|
+
// The standalone output has native modules stripped — better-sqlite3 and sharp
|
|
167
|
+
// are installed as real npm dependencies so they compile for the user's platform.
|
|
168
|
+
const packageNodeModules = path.join(PACKAGE_DIR, 'node_modules');
|
|
169
|
+
env.NODE_PATH = env.NODE_PATH
|
|
170
|
+
? `${packageNodeModules}${path.delimiter}${env.NODE_PATH}`
|
|
171
|
+
: packageNodeModules;
|
|
172
|
+
|
|
173
|
+
const url = `http://localhost:${opts.port}`;
|
|
174
|
+
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(` Quilltap v${version}`);
|
|
177
|
+
console.log('');
|
|
178
|
+
console.log(` URL: ${url}`);
|
|
179
|
+
if (opts.dataDir) {
|
|
180
|
+
console.log(` Data dir: ${env.QUILLTAP_DATA_DIR}`);
|
|
181
|
+
}
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(' Starting server...');
|
|
184
|
+
console.log('');
|
|
185
|
+
|
|
186
|
+
// Fork the Next.js standalone server
|
|
187
|
+
const child = fork(serverJs, [], {
|
|
188
|
+
cwd: standaloneDir,
|
|
189
|
+
env,
|
|
190
|
+
stdio: 'inherit',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Open browser once server is listening
|
|
194
|
+
if (opts.open) {
|
|
195
|
+
setTimeout(() => openBrowser(url), 2000);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Forward signals for graceful shutdown
|
|
199
|
+
function shutdown(signal) {
|
|
200
|
+
child.kill(signal);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
204
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
205
|
+
|
|
206
|
+
child.on('exit', (code, signal) => {
|
|
207
|
+
if (signal) {
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
process.exit(code || 0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
child.on('error', (err) => {
|
|
214
|
+
console.error('Failed to start server:', err.message);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
main();
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Download manager for the Quilltap standalone tarball.
|
|
5
|
+
*
|
|
6
|
+
* On first run (or version mismatch), downloads the pre-built standalone
|
|
7
|
+
* output from GitHub Releases and caches it locally. Subsequent runs
|
|
8
|
+
* start instantly from the cache.
|
|
9
|
+
*
|
|
10
|
+
* Uses only Node.js built-ins — no external dependencies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { extractTarGz } = require('./tar-extract');
|
|
19
|
+
|
|
20
|
+
const MAX_RETRIES = 3;
|
|
21
|
+
const PROGRESS_THROTTLE_MS = 250;
|
|
22
|
+
const GITHUB_REPO = 'foundry-9/quilltap';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get the platform-specific cache directory for standalone files.
|
|
26
|
+
* Follows the same conventions as the Electron app.
|
|
27
|
+
*/
|
|
28
|
+
function getCacheDir() {
|
|
29
|
+
const platform = process.platform;
|
|
30
|
+
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
return path.join(os.homedir(), 'Library', 'Caches', 'Quilltap', 'standalone');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
37
|
+
return path.join(localAppData, 'Quilltap', 'standalone');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Linux and others: XDG_CACHE_HOME or ~/.cache
|
|
41
|
+
const cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
42
|
+
return path.join(cacheHome, 'quilltap', 'standalone');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if the cached standalone matches the expected version.
|
|
47
|
+
* @param {string} cacheDir - The cache directory
|
|
48
|
+
* @param {string} version - Expected version
|
|
49
|
+
* @returns {boolean} true if cache is valid and matches version
|
|
50
|
+
*/
|
|
51
|
+
function isCacheValid(cacheDir, version) {
|
|
52
|
+
const versionFile = path.join(cacheDir, '.version');
|
|
53
|
+
const serverJs = path.join(cacheDir, 'server.js');
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(serverJs)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const cachedVersion = fs.readFileSync(versionFile, 'utf-8').trim();
|
|
61
|
+
return cachedVersion === version;
|
|
62
|
+
} catch {
|
|
63
|
+
// No version file — cache is invalid
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the download URL for a given version.
|
|
70
|
+
* @param {string} version
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function getDownloadUrl(version) {
|
|
74
|
+
return `https://github.com/${GITHUB_REPO}/releases/download/${version}/quilltap-standalone-${version}.tar.gz`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format bytes into a human-readable string.
|
|
79
|
+
* @param {number} bytes
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function formatBytes(bytes) {
|
|
83
|
+
if (bytes >= 1024 * 1024 * 1024) {
|
|
84
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
85
|
+
}
|
|
86
|
+
if (bytes >= 1024 * 1024) {
|
|
87
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
88
|
+
}
|
|
89
|
+
if (bytes >= 1024) {
|
|
90
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
91
|
+
}
|
|
92
|
+
return `${bytes} B`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format bytes/second into a speed string.
|
|
97
|
+
* @param {number} bytesPerSecond
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function formatSpeed(bytesPerSecond) {
|
|
101
|
+
if (bytesPerSecond >= 1024 * 1024) {
|
|
102
|
+
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
103
|
+
}
|
|
104
|
+
if (bytesPerSecond >= 1024) {
|
|
105
|
+
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
|
106
|
+
}
|
|
107
|
+
return `${Math.round(bytesPerSecond)} B/s`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render a terminal progress bar.
|
|
112
|
+
* @param {number} percent
|
|
113
|
+
* @param {number} received
|
|
114
|
+
* @param {number} total
|
|
115
|
+
* @param {string} speed
|
|
116
|
+
*/
|
|
117
|
+
function renderProgress(percent, received, total, speed) {
|
|
118
|
+
const barWidth = 30;
|
|
119
|
+
const filled = Math.round((percent / 100) * barWidth);
|
|
120
|
+
const empty = barWidth - filled;
|
|
121
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
|
|
122
|
+
|
|
123
|
+
const totalStr = total > 0 ? formatBytes(total) : '?';
|
|
124
|
+
const line = ` Downloading: ${bar} ${percent}% (${formatBytes(received)}/${totalStr}) ${speed}`;
|
|
125
|
+
|
|
126
|
+
// Clear line and write progress
|
|
127
|
+
process.stdout.write('\r' + line + ' ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Download a file from a URL, following redirects.
|
|
132
|
+
* @param {string} url
|
|
133
|
+
* @param {string} destPath - Path to save the downloaded file
|
|
134
|
+
* @returns {Promise<void>}
|
|
135
|
+
*/
|
|
136
|
+
function downloadFile(url, destPath) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const protocol = url.startsWith('https') ? https : http;
|
|
139
|
+
|
|
140
|
+
const request = protocol.get(url, { headers: { 'User-Agent': 'quilltap-cli' } }, (response) => {
|
|
141
|
+
// Handle redirects (GitHub releases redirect to S3)
|
|
142
|
+
if (
|
|
143
|
+
response.statusCode &&
|
|
144
|
+
response.statusCode >= 300 &&
|
|
145
|
+
response.statusCode < 400 &&
|
|
146
|
+
response.headers.location
|
|
147
|
+
) {
|
|
148
|
+
downloadFile(response.headers.location, destPath).then(resolve).catch(reject);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (response.statusCode !== 200) {
|
|
153
|
+
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
|
|
158
|
+
let bytesReceived = 0;
|
|
159
|
+
let lastProgressTime = 0;
|
|
160
|
+
let lastProgressBytes = 0;
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
|
|
163
|
+
const tempPath = destPath + '.tmp';
|
|
164
|
+
const fileStream = fs.createWriteStream(tempPath);
|
|
165
|
+
|
|
166
|
+
response.on('data', (chunk) => {
|
|
167
|
+
bytesReceived += chunk.length;
|
|
168
|
+
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
if (now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
|
|
171
|
+
const elapsed = (now - lastProgressTime) / 1000;
|
|
172
|
+
const bytesInPeriod = bytesReceived - lastProgressBytes;
|
|
173
|
+
const speedBps = elapsed > 0 ? bytesInPeriod / elapsed : 0;
|
|
174
|
+
const percent = totalBytes > 0 ? Math.round((bytesReceived / totalBytes) * 100) : 0;
|
|
175
|
+
|
|
176
|
+
renderProgress(percent, bytesReceived, totalBytes, formatSpeed(speedBps));
|
|
177
|
+
|
|
178
|
+
lastProgressTime = now;
|
|
179
|
+
lastProgressBytes = bytesReceived;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
response.pipe(fileStream);
|
|
184
|
+
|
|
185
|
+
fileStream.on('finish', () => {
|
|
186
|
+
fileStream.close(() => {
|
|
187
|
+
// Clear progress line
|
|
188
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
189
|
+
|
|
190
|
+
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
191
|
+
console.log(` Downloaded ${formatBytes(bytesReceived)} in ${totalTime}s`);
|
|
192
|
+
|
|
193
|
+
// Move temp file to final location
|
|
194
|
+
fs.renameSync(tempPath, destPath);
|
|
195
|
+
resolve();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
fileStream.on('error', (err) => {
|
|
200
|
+
try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
|
|
201
|
+
reject(err);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
response.on('error', reject);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
request.on('error', reject);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Download and extract the standalone tarball for the given version.
|
|
213
|
+
* Retries with exponential backoff on failure.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} version - The version to download
|
|
216
|
+
* @param {string} cacheDir - The cache directory
|
|
217
|
+
* @param {object} [options]
|
|
218
|
+
* @param {boolean} [options.force] - Force re-download even if cache is valid
|
|
219
|
+
* @returns {Promise<string>} Path to the standalone directory
|
|
220
|
+
*/
|
|
221
|
+
async function ensureStandalone(version, cacheDir, options = {}) {
|
|
222
|
+
if (!options.force && isCacheValid(cacheDir, version)) {
|
|
223
|
+
return cacheDir;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const url = getDownloadUrl(version);
|
|
227
|
+
const tarballPath = path.join(os.tmpdir(), `quilltap-standalone-${version}.tar.gz`);
|
|
228
|
+
|
|
229
|
+
console.log('');
|
|
230
|
+
console.log(` Quilltap v${version} — first-run setup`);
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log(' The application files need to be downloaded. This only');
|
|
233
|
+
console.log(' happens once per version and takes about a minute.');
|
|
234
|
+
console.log('');
|
|
235
|
+
|
|
236
|
+
let lastError;
|
|
237
|
+
|
|
238
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
239
|
+
try {
|
|
240
|
+
await downloadFile(url, tarballPath);
|
|
241
|
+
|
|
242
|
+
// Clean existing cache
|
|
243
|
+
if (fs.existsSync(cacheDir)) {
|
|
244
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
247
|
+
|
|
248
|
+
// Extract
|
|
249
|
+
console.log(' Extracting...');
|
|
250
|
+
await extractTarGz(tarballPath, cacheDir);
|
|
251
|
+
|
|
252
|
+
// Write version sidecar
|
|
253
|
+
fs.writeFileSync(path.join(cacheDir, '.version'), version, 'utf-8');
|
|
254
|
+
|
|
255
|
+
// Clean up tarball
|
|
256
|
+
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
|
|
257
|
+
|
|
258
|
+
console.log(' Ready!');
|
|
259
|
+
console.log('');
|
|
260
|
+
|
|
261
|
+
return cacheDir;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
264
|
+
console.error(` Download attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}`);
|
|
265
|
+
|
|
266
|
+
// Clean up partial downloads
|
|
267
|
+
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
|
|
268
|
+
|
|
269
|
+
if (attempt < MAX_RETRIES) {
|
|
270
|
+
const delayMs = Math.pow(2, attempt) * 1000;
|
|
271
|
+
console.log(` Retrying in ${delayMs / 1000}s...`);
|
|
272
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Failed to download Quilltap standalone after ${MAX_RETRIES} attempts.\n` +
|
|
279
|
+
` URL: ${url}\n` +
|
|
280
|
+
` Error: ${lastError ? lastError.message : 'Unknown error'}\n\n` +
|
|
281
|
+
` Please check your internet connection and try again.\n` +
|
|
282
|
+
` If the problem persists, you can download manually from:\n` +
|
|
283
|
+
` https://github.com/${GITHUB_REPO}/releases`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
getCacheDir,
|
|
289
|
+
isCacheValid,
|
|
290
|
+
ensureStandalone,
|
|
291
|
+
getDownloadUrl,
|
|
292
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal tar.gz extractor using only Node.js built-ins.
|
|
5
|
+
* Handles POSIX tar format (ustar) — sufficient for extracting
|
|
6
|
+
* the Quilltap standalone tarball.
|
|
7
|
+
*
|
|
8
|
+
* No external dependencies required.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const zlib = require('zlib');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract a .tar.gz file to the given directory.
|
|
17
|
+
* @param {string} tarGzPath - Path to the .tar.gz file
|
|
18
|
+
* @param {string} destDir - Directory to extract into
|
|
19
|
+
* @returns {Promise<void>}
|
|
20
|
+
*/
|
|
21
|
+
function extractTarGz(tarGzPath, destDir) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const readStream = fs.createReadStream(tarGzPath);
|
|
24
|
+
const gunzip = zlib.createGunzip();
|
|
25
|
+
const chunks = [];
|
|
26
|
+
|
|
27
|
+
readStream.pipe(gunzip);
|
|
28
|
+
|
|
29
|
+
gunzip.on('data', (chunk) => chunks.push(chunk));
|
|
30
|
+
gunzip.on('error', reject);
|
|
31
|
+
readStream.on('error', reject);
|
|
32
|
+
|
|
33
|
+
gunzip.on('end', () => {
|
|
34
|
+
try {
|
|
35
|
+
const tarBuffer = Buffer.concat(chunks);
|
|
36
|
+
extractTarBuffer(tarBuffer, destDir);
|
|
37
|
+
resolve();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
reject(err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse and extract files from a raw tar buffer.
|
|
47
|
+
* @param {Buffer} buffer - The uncompressed tar data
|
|
48
|
+
* @param {string} destDir - Directory to extract into
|
|
49
|
+
*/
|
|
50
|
+
function extractTarBuffer(buffer, destDir) {
|
|
51
|
+
let offset = 0;
|
|
52
|
+
|
|
53
|
+
while (offset + 512 <= buffer.length) {
|
|
54
|
+
const header = buffer.subarray(offset, offset + 512);
|
|
55
|
+
|
|
56
|
+
// Check for end-of-archive (two consecutive zero blocks)
|
|
57
|
+
if (isZeroBlock(header)) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parsed = parseHeader(header);
|
|
62
|
+
if (!parsed) {
|
|
63
|
+
// Skip malformed header
|
|
64
|
+
offset += 512;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
offset += 512; // Move past header
|
|
69
|
+
|
|
70
|
+
const { name, size, type, linkName } = parsed;
|
|
71
|
+
|
|
72
|
+
// Security: prevent path traversal
|
|
73
|
+
const safeName = name.replace(/^\.\//, '');
|
|
74
|
+
if (safeName.startsWith('/') || safeName.includes('..')) {
|
|
75
|
+
// Skip dangerous paths
|
|
76
|
+
offset += Math.ceil(size / 512) * 512;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fullPath = path.join(destDir, safeName);
|
|
81
|
+
|
|
82
|
+
switch (type) {
|
|
83
|
+
case 'directory':
|
|
84
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
85
|
+
break;
|
|
86
|
+
|
|
87
|
+
case 'file': {
|
|
88
|
+
// Ensure parent directory exists
|
|
89
|
+
const dir = path.dirname(fullPath);
|
|
90
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
91
|
+
|
|
92
|
+
const fileData = buffer.subarray(offset, offset + size);
|
|
93
|
+
fs.writeFileSync(fullPath, fileData);
|
|
94
|
+
|
|
95
|
+
// Set executable permission if needed
|
|
96
|
+
if (parsed.mode & 0o111) {
|
|
97
|
+
try {
|
|
98
|
+
fs.chmodSync(fullPath, parsed.mode);
|
|
99
|
+
} catch {
|
|
100
|
+
// chmod may fail on Windows, that's fine
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'symlink': {
|
|
107
|
+
const dir = path.dirname(fullPath);
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
try {
|
|
110
|
+
fs.symlinkSync(linkName, fullPath);
|
|
111
|
+
} catch {
|
|
112
|
+
// Symlinks may fail on Windows without privileges
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Skip other types (hard links, etc.)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Advance past file data (padded to 512-byte boundary)
|
|
121
|
+
offset += Math.ceil(size / 512) * 512;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse a 512-byte tar header block.
|
|
127
|
+
* @param {Buffer} header
|
|
128
|
+
* @returns {{ name: string, size: number, type: string, mode: number, linkName: string } | null}
|
|
129
|
+
*/
|
|
130
|
+
function parseHeader(header) {
|
|
131
|
+
// Validate checksum
|
|
132
|
+
const storedChecksum = parseOctal(header, 148, 8);
|
|
133
|
+
const computedChecksum = computeChecksum(header);
|
|
134
|
+
if (storedChecksum !== computedChecksum) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const name = parseName(header, 0, 100);
|
|
139
|
+
const mode = parseOctal(header, 100, 8);
|
|
140
|
+
const size = parseOctal(header, 124, 12);
|
|
141
|
+
const typeFlag = String.fromCharCode(header[156]);
|
|
142
|
+
const linkName = parseName(header, 157, 100);
|
|
143
|
+
|
|
144
|
+
// UStar prefix (extends the name field for longer paths)
|
|
145
|
+
const magic = header.subarray(257, 263).toString('ascii');
|
|
146
|
+
let fullName = name;
|
|
147
|
+
if (magic.startsWith('ustar')) {
|
|
148
|
+
const prefix = parseName(header, 345, 155);
|
|
149
|
+
if (prefix) {
|
|
150
|
+
fullName = prefix + '/' + name;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Determine entry type
|
|
155
|
+
let type;
|
|
156
|
+
switch (typeFlag) {
|
|
157
|
+
case '0':
|
|
158
|
+
case '\0':
|
|
159
|
+
case '': // Regular file
|
|
160
|
+
type = 'file';
|
|
161
|
+
break;
|
|
162
|
+
case '2': // Symbolic link
|
|
163
|
+
type = 'symlink';
|
|
164
|
+
break;
|
|
165
|
+
case '5': // Directory
|
|
166
|
+
type = 'directory';
|
|
167
|
+
break;
|
|
168
|
+
default:
|
|
169
|
+
type = 'other';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A name ending with '/' is also a directory
|
|
173
|
+
if (fullName.endsWith('/') && type === 'file' && size === 0) {
|
|
174
|
+
type = 'directory';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { name: fullName, size, type, mode, linkName };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read a null-terminated string from a buffer region.
|
|
182
|
+
*/
|
|
183
|
+
function parseName(buffer, offset, length) {
|
|
184
|
+
const slice = buffer.subarray(offset, offset + length);
|
|
185
|
+
const nullIndex = slice.indexOf(0);
|
|
186
|
+
const str = slice.subarray(0, nullIndex >= 0 ? nullIndex : length).toString('utf-8');
|
|
187
|
+
return str;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse an octal number from a buffer region.
|
|
192
|
+
*/
|
|
193
|
+
function parseOctal(buffer, offset, length) {
|
|
194
|
+
const slice = buffer.subarray(offset, offset + length);
|
|
195
|
+
const str = slice.toString('ascii').replace(/\0/g, '').trim();
|
|
196
|
+
if (!str) return 0;
|
|
197
|
+
return parseInt(str, 8) || 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Compute the checksum of a tar header (treat checksum field as spaces).
|
|
202
|
+
*/
|
|
203
|
+
function computeChecksum(header) {
|
|
204
|
+
let sum = 0;
|
|
205
|
+
for (let i = 0; i < 512; i++) {
|
|
206
|
+
// The checksum field (bytes 148-155) is treated as spaces (0x20)
|
|
207
|
+
if (i >= 148 && i < 156) {
|
|
208
|
+
sum += 0x20;
|
|
209
|
+
} else {
|
|
210
|
+
sum += header[i];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return sum;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if a 512-byte block is all zeros.
|
|
218
|
+
*/
|
|
219
|
+
function isZeroBlock(block) {
|
|
220
|
+
for (let i = 0; i < 512; i++) {
|
|
221
|
+
if (block[i] !== 0) return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { extractTarGz };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quilltap",
|
|
3
|
+
"version": "3.0.0-dev.79",
|
|
4
|
+
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Charles Sebold",
|
|
7
|
+
"email": "charles.sebold@foundry-9.com",
|
|
8
|
+
"url": "https://foundry-9.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/foundry-9/quilltap.git",
|
|
14
|
+
"directory": "packages/quilltap"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://quilltap.ai",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"quilltap",
|
|
19
|
+
"ai",
|
|
20
|
+
"llm",
|
|
21
|
+
"chat",
|
|
22
|
+
"roleplay",
|
|
23
|
+
"writing",
|
|
24
|
+
"worldbuilding",
|
|
25
|
+
"self-hosted"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"quilltap": "bin/quilltap.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin/",
|
|
32
|
+
"lib/",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"better-sqlite3": "^12.6.2",
|
|
37
|
+
"sharp": "^0.34.5"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|