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 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
@@ -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
+ }