@pajamadot/story-cli 0.1.0

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/bin/story.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Thin shim that locates the platform-specific story binary
5
+ * (downloaded by postinstall.js) and spawns it with the user's args.
6
+ */
7
+
8
+ const { spawn } = require('child_process');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+
12
+ const ext = process.platform === 'win32' ? '.exe' : '';
13
+ const binPath = path.join(__dirname, `story${ext}`);
14
+
15
+ if (!fs.existsSync(binPath)) {
16
+ console.error(
17
+ `[story] Binary not found at ${binPath}\n\n` +
18
+ `This usually means the postinstall download failed.\n` +
19
+ `Try:\n` +
20
+ ` npm rebuild @pajamadot/story-cli\n` +
21
+ ` npm install -g @pajamadot/story-cli\n\n` +
22
+ `Or build from source:\n` +
23
+ ` cd story-cli && cargo build --release`
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ const child = spawn(binPath, process.argv.slice(2), {
29
+ stdio: 'inherit',
30
+ windowsHide: false,
31
+ });
32
+
33
+ // Forward signals to the child process
34
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
35
+ try {
36
+ process.on(sig, () => child.kill(sig));
37
+ } catch {
38
+ // SIGHUP may not exist on Windows
39
+ }
40
+ }
41
+
42
+ child.on('error', (err) => {
43
+ if (err.code === 'EACCES') {
44
+ console.error(
45
+ `[story] Permission denied running ${binPath}\n` +
46
+ `Try: chmod +x "${binPath}"`
47
+ );
48
+ } else {
49
+ console.error(`[story] Failed to run binary: ${err.message}`);
50
+ }
51
+ process.exit(1);
52
+ });
53
+
54
+ child.on('exit', (code, signal) => {
55
+ if (signal) {
56
+ // Re-raise the signal so the parent shell sees it
57
+ process.kill(process.pid, signal);
58
+ } else {
59
+ process.exit(code ?? 1);
60
+ }
61
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@pajamadot/story-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for PajamaDot Story Platform – create visual novels from the terminal",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/pajamadot/story"
9
+ },
10
+ "bin": {
11
+ "story": "bin/story.js"
12
+ },
13
+ "scripts": {
14
+ "postinstall": "node postinstall.js"
15
+ },
16
+ "files": [
17
+ "bin/story.js",
18
+ "postinstall.js"
19
+ ],
20
+ "engines": {
21
+ "node": ">=16"
22
+ },
23
+ "keywords": [
24
+ "story",
25
+ "visual-novel",
26
+ "cli",
27
+ "pajamadot",
28
+ "narrative"
29
+ ]
30
+ }
package/postinstall.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * postinstall.js – downloads the platform-specific story binary
3
+ * from the PajamaDot CDN (R2) and places it in bin/.
4
+ *
5
+ * Layout on CDN (R2 bucket: pajamadot-releases):
6
+ * <cdn>/cli/v0.1.0/story-x86_64-pc-windows-msvc.exe
7
+ * <cdn>/cli/v0.1.0/story-aarch64-apple-darwin
8
+ * <cdn>/cli/v0.1.0/checksums.sha256
9
+ * <cdn>/cli/latest.json → { "version": "0.1.0" }
10
+ */
11
+
12
+ const https = require('https');
13
+ const http = require('http');
14
+ const crypto = require('crypto');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const pkg = require('./package.json');
19
+ const VERSION = pkg.version;
20
+
21
+ const CDN_BASE = process.env.STORY_CDN_URL || 'https://releases.pajamadot.com';
22
+
23
+ const PLATFORM_MAP = {
24
+ 'win32-x64': 'x86_64-pc-windows-msvc',
25
+ 'linux-x64': 'x86_64-unknown-linux-gnu',
26
+ 'linux-arm64': 'aarch64-unknown-linux-gnu',
27
+ 'darwin-x64': 'x86_64-apple-darwin',
28
+ 'darwin-arm64': 'aarch64-apple-darwin',
29
+ };
30
+
31
+ const MAX_RETRIES = 3;
32
+ const RETRY_BASE_MS = 1000;
33
+
34
+ function getPlatformKey() {
35
+ return `${process.platform}-${process.arch}`;
36
+ }
37
+
38
+ function getBinaryName() {
39
+ const ext = process.platform === 'win32' ? '.exe' : '';
40
+ return `story${ext}`;
41
+ }
42
+
43
+ function getTarget() {
44
+ const key = getPlatformKey();
45
+ const target = PLATFORM_MAP[key];
46
+ if (!target) {
47
+ throw new Error(
48
+ `Unsupported platform: ${key}\n` +
49
+ `Supported: ${Object.keys(PLATFORM_MAP).join(', ')}`
50
+ );
51
+ }
52
+ return target;
53
+ }
54
+
55
+ function getBinaryFilename() {
56
+ const ext = process.platform === 'win32' ? '.exe' : '';
57
+ return `story-${getTarget()}${ext}`;
58
+ }
59
+
60
+ function getDownloadUrl() {
61
+ return `${CDN_BASE}/cli/v${VERSION}/${getBinaryFilename()}`;
62
+ }
63
+
64
+ function getChecksumsUrl() {
65
+ return `${CDN_BASE}/cli/v${VERSION}/checksums.sha256`;
66
+ }
67
+
68
+ function fetchBuffer(url, redirectCount) {
69
+ if (redirectCount === undefined) redirectCount = 0;
70
+ return new Promise((resolve, reject) => {
71
+ if (redirectCount > 10) {
72
+ return reject(new Error('Too many redirects'));
73
+ }
74
+
75
+ const lib = url.startsWith('https') ? https : http;
76
+ const req = lib.get(url, { headers: { 'User-Agent': 'story-cli-postinstall' } }, (res) => {
77
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
78
+ return resolve(fetchBuffer(res.headers.location, redirectCount + 1));
79
+ }
80
+
81
+ if (res.statusCode !== 200) {
82
+ // Consume body to free socket
83
+ res.resume();
84
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
85
+ }
86
+
87
+ const chunks = [];
88
+ res.on('data', (chunk) => chunks.push(chunk));
89
+ res.on('end', () => resolve(Buffer.concat(chunks)));
90
+ res.on('error', reject);
91
+ });
92
+ req.on('error', reject);
93
+ req.setTimeout(60000, () => {
94
+ req.destroy(new Error('Request timed out'));
95
+ });
96
+ });
97
+ }
98
+
99
+ async function fetchWithRetry(url, label) {
100
+ let lastErr;
101
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
102
+ try {
103
+ return await fetchBuffer(url);
104
+ } catch (err) {
105
+ lastErr = err;
106
+ if (attempt < MAX_RETRIES) {
107
+ const delay = RETRY_BASE_MS * Math.pow(2, attempt - 1);
108
+ console.log(`[story] ${label} attempt ${attempt} failed: ${err.message} — retrying in ${delay}ms`);
109
+ await new Promise(r => setTimeout(r, delay));
110
+ }
111
+ }
112
+ }
113
+ throw lastErr;
114
+ }
115
+
116
+ function sha256hex(buffer) {
117
+ return crypto.createHash('sha256').update(buffer).digest('hex');
118
+ }
119
+
120
+ async function verifyChecksum(data, filename) {
121
+ try {
122
+ const checksumsBuffer = await fetchWithRetry(getChecksumsUrl(), 'checksums');
123
+ const checksums = checksumsBuffer.toString('utf8');
124
+ // Format: "<hex> <filename>" per line (sha256sum output format)
125
+ for (const rawLine of checksums.split('\n')) {
126
+ const line = rawLine.replace(/\r$/, '');
127
+ const match = line.match(/^([a-f0-9]{64})\s+(.+)$/);
128
+ if (match && match[2].trim() === filename) {
129
+ const expected = match[1];
130
+ const actual = sha256hex(data);
131
+ if (actual !== expected) {
132
+ throw new Error(
133
+ `Checksum mismatch for ${filename}!\n` +
134
+ ` Expected: ${expected}\n` +
135
+ ` Got: ${actual}`
136
+ );
137
+ }
138
+ console.log(`[story] Checksum verified: ${filename}`);
139
+ return;
140
+ }
141
+ }
142
+ // No matching entry — warn but don't fail
143
+ console.log(`[story] No checksum entry found for ${filename} — skipping verification`);
144
+ } catch (err) {
145
+ if (err.message.includes('Checksum mismatch')) {
146
+ throw err; // Always fail on mismatch
147
+ }
148
+ // Checksum file missing or unreachable — warn but continue
149
+ console.log(`[story] Could not verify checksum: ${err.message}`);
150
+ }
151
+ }
152
+
153
+ async function main() {
154
+ const binDir = path.join(__dirname, 'bin');
155
+ const binName = getBinaryName();
156
+ const destPath = path.join(binDir, binName);
157
+
158
+ // Skip if binary already exists (e.g. CI pre-built or manual placement)
159
+ if (fs.existsSync(destPath)) {
160
+ console.log(`[story] Binary already exists at ${destPath}`);
161
+ return;
162
+ }
163
+
164
+ const filename = getBinaryFilename();
165
+ const url = getDownloadUrl();
166
+ console.log(`[story] Downloading story CLI v${VERSION} for ${getPlatformKey()}`);
167
+ console.log(`[story] URL: ${url}`);
168
+
169
+ try {
170
+ const data = await fetchWithRetry(url, 'download');
171
+
172
+ // Verify SHA256 checksum
173
+ await verifyChecksum(data, filename);
174
+
175
+ fs.mkdirSync(binDir, { recursive: true });
176
+ fs.writeFileSync(destPath, data);
177
+
178
+ // Make executable on Unix
179
+ if (process.platform !== 'win32') {
180
+ fs.chmodSync(destPath, 0o755);
181
+ }
182
+
183
+ console.log(`[story] Installed story CLI v${VERSION} to ${destPath}`);
184
+ } catch (err) {
185
+ console.error(
186
+ `[story] Failed to download binary: ${err.message}\n\n` +
187
+ `You can manually download from:\n ${url}\n` +
188
+ `and place it at:\n ${destPath}\n\n` +
189
+ `Or build from source: cd story-cli && cargo build --release`
190
+ );
191
+ // Don't fail the install – the shim will report the missing binary at runtime
192
+ }
193
+ }
194
+
195
+ main();