wiki-plugin-shoppe 0.0.21 → 0.0.23

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/CLAUDE.md CHANGED
@@ -17,13 +17,25 @@ The first 3 emoji are fixed per wiki instance (`SHOPPE_BASE_EMOJI`, default `
17
17
  ### Tenant Lifecycle
18
18
 
19
19
  1. Wiki owner registers a tenant: `POST /plugin/shoppe/register { name }`
20
- - Plugin creates a Sanora user for the tenant
21
- - Generates emojicode, stores `{ uuid, emojicode, name, keys }` in `.shoppe-tenants.json`
22
- - Returns `{ uuid, emojicode }` tenant puts these in their `manifest.json`
23
- 2. Tenant builds their archive (see format below)
24
- 3. Tenant drags archive onto the wiki plugin widget
25
- 4. Plugin verifies `uuid + emojicode`, processes all goods into Sanora
26
- 5. Shoppe accessible at `/plugin/shoppe/:uuid` or `/plugin/shoppe/:emojicode`
20
+ - Plugin creates a Sanora user + an Addie user for the tenant
21
+ - Generates emojicode + owner keypair (secp256k1 via sessionless)
22
+ - Stores `{ uuid, emojicode, name, keys, addieKeys, ownerPubKey }` in `~/.shoppe/tenants.json`
23
+ - Owner private key is delivered once via a single-use starter bundle download (bundleToken)
24
+ 2. Tenant runs `node shoppe-sign.js init` moves private key to `~/.shoppe/keys/<uuid>.json`
25
+ 3. Tenant runs `node shoppe-sign.js payouts` completes Stripe Connect Express onboarding
26
+ 4. Tenant builds their archive and runs `node shoppe-sign.js` to sign and zip
27
+ 5. Tenant drags the zip onto the wiki plugin widget
28
+ 6. Plugin verifies `uuid + emojicode + owner signature`, processes all goods into Sanora
29
+ 7. Shoppe accessible at `/plugin/shoppe/:uuid` or `/plugin/shoppe/:emojicode`
30
+
31
+ ### shoppe-sign.js commands
32
+
33
+ | Command | Description |
34
+ |---------|-------------|
35
+ | `node shoppe-sign.js init` | Move `shoppe-key.json` to `~/.shoppe/keys/<uuid>.json` (no npm needed) |
36
+ | `node shoppe-sign.js` | Sign manifest + create upload.zip |
37
+ | `node shoppe-sign.js orders [wiki-url]` | Generate signed orders URL (5-min expiry), open in browser |
38
+ | `node shoppe-sign.js payouts [wiki-url]` | Generate signed payouts URL, redirect to Stripe Connect onboarding |
27
39
 
28
40
  ## Archive Format
29
41
 
@@ -86,7 +98,24 @@ my-shoppe.zip
86
98
  {
87
99
  "uuid": "your-uuid-from-registration",
88
100
  "emojicode": "🛍️🎨🎁🌟💎🐉📚🔥",
89
- "name": "My Shoppe"
101
+ "name": "My Shoppe",
102
+ "keywords": ["digital goods", "indie creator", "music", "books"]
103
+ }
104
+ ```
105
+
106
+ `keywords` is optional. When present, it is stored in the tenant record and rendered as a `<meta name="keywords">` tag on the main shoppe page.
107
+
108
+ `redirects` is optional. Each key is a content category (`books`, `music`, `posts`, `albums`, `products`, `appointments`, `subscriptions`) and the value is an external URL. When set, clicking any card in that category sends visitors to that URL instead of the plugin's built-in purchase/download pages. Example:
109
+
110
+ ```json
111
+ {
112
+ "uuid": "...",
113
+ "emojicode": "...",
114
+ "name": "My Shoppe",
115
+ "redirects": {
116
+ "books": "https://myauthorsite.com/books",
117
+ "music": "https://mybandcamp.com"
118
+ }
90
119
  }
91
120
  ```
92
121
 
@@ -97,10 +126,13 @@ my-shoppe.zip
97
126
  "title": "My Novel",
98
127
  "description": "A gripping tale",
99
128
  "price": 9,
100
- "cover": "front.jpg"
129
+ "cover": "front.jpg",
130
+ "keywords": ["fiction", "thriller", "indie author"]
101
131
  }
102
132
  ```
103
133
 
134
+ `keywords` is optional on all `info.json` files. Values are stored as `kw:`-prefixed entries in Sanora's product tags and rendered as `<meta name="keywords">` on that product's pages.
135
+
104
136
  `cover` pins a specific image file as the Sanora cover image. If omitted, the first image in the folder is used.
105
137
 
106
138
  ### music/*/info.json (albums)
@@ -207,9 +239,21 @@ The hero image is resolved automatically: `hero.jpg` or `hero.png` is used if pr
207
239
  | `POST` | `/plugin/shoppe/:id/purchase/complete` | Public | Record completed purchase |
208
240
  | `GET` | `/plugin/shoppe/:id/download/:title` | Public | Ebook download page |
209
241
  | `GET` | `/plugin/shoppe/:id/post/:title` | Public | Post reader |
242
+ | `GET` | `/plugin/shoppe/:id/orders` | Owner (signed URL) | Order history page |
243
+ | `GET` | `/plugin/shoppe/:id/payouts` | Owner (signed URL) | Stripe Connect Express onboarding |
244
+ | `GET` | `/plugin/shoppe/:id/payouts/return` | Public | Post-Stripe-Connect confirmation page |
210
245
 
211
246
  `:id` accepts either UUID or emojicode.
212
247
 
248
+ ## Payment / Transfer Flow
249
+
250
+ 1. Buyer calls `POST /purchase/intent` → shoppe creates a buyer Addie user and calls `PUT /user/:buyerUuid/processor/stripe/intent` on Addie → returns `{ clientSecret, publishableKey }`
251
+ 2. Stripe.js confirms payment client-side (no redirect)
252
+ 3. Client extracts `paymentIntentId` from `clientSecret` (`clientSecret.split('_secret_')[0]`) and posts to `POST /purchase/complete` with `paymentIntentId`
253
+ 4. Server records the order in Sanora, then fires a **fire-and-forget** `POST ${addieUrl}/payment/${paymentIntentId}/process-transfers` — Addie splits the payment and routes it to the tenant's Stripe account (no auth required on this Addie endpoint)
254
+
255
+ **Important:** Transfers only flow to the owner after `node shoppe-sign.js payouts` has been run and Stripe Connect onboarding is complete.
256
+
213
257
  ## Configuration
214
258
 
215
259
  ```bash
package/client/shoppe.js CHANGED
@@ -17,7 +17,7 @@
17
17
  .sw-step-body { font-size: 14px; line-height: 1.5; color: #333; }
18
18
  .sw-step-body strong { color: #1d1d1f; }
19
19
  .sw-step-body code { background: #e8e8ed; border-radius: 4px; padding: 1px 5px; font-size: 12px; }
20
- .sw-tree { font-family: monospace; font-size: 12px; background: #1d1d1f; color: #a8f0a8; border-radius: 8px; padding: 14px 16px; line-height: 1.7; white-space: pre; overflow-x: auto; margin-top: 8px; }
20
+ .sw-tree { font-family: monospace; font-size: 12px; background: #1d1d1f; color: #a8f0a8; border-radius: 8px; padding: 14px 16px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; margin-top: 8px; }
21
21
  .sw-shoppe { display: flex; align-items: center; justify-content: space-between; background: white; border: 1px solid #e5e5ea; border-radius: 10px; padding: 12px 16px; margin-bottom: 8px; }
22
22
  .sw-shoppe-left { display: flex; flex-direction: column; gap: 2px; }
23
23
  .sw-shoppe-name { font-weight: 600; font-size: 15px; }
@@ -57,11 +57,15 @@
57
57
  <div class="sw-card">
58
58
  <div class="sw-step">
59
59
  <div class="sw-step-num">1</div>
60
- <div class="sw-step-body"><strong>Ask the wiki owner to register you.</strong> They'll use the form at the bottom of this page and give you a <code>uuid</code> and <code>emojicode</code> your shoppe's identity.</div>
60
+ <div class="sw-step-body"><strong>Ask the wiki owner to register you.</strong> They'll download a <strong>starter bundle</strong> a zip with your signing key, a template folder structure, and the <code>shoppe-sign.js</code> utility. Run <code>node shoppe-sign.js init</code> to store your key securely.</div>
61
61
  </div>
62
62
  <div class="sw-step">
63
63
  <div class="sw-step-num">2</div>
64
- <div class="sw-step-body"><strong>Build your shoppe folder</strong> with this structure, then zip the whole thing:
64
+ <div class="sw-step-body"><strong>Set up payouts</strong> by running <code>node shoppe-sign.js payouts</code>. This opens Stripe Connect onboarding so you can receive payments. Do this once before your first sale.</div>
65
+ </div>
66
+ <div class="sw-step">
67
+ <div class="sw-step-num">3</div>
68
+ <div class="sw-step-body"><strong>Build your shoppe folder</strong> with this structure:
65
69
  <div class="sw-tree">my-shoppe.zip
66
70
  manifest.json ← { "uuid": "…", "emojicode": "…", "name": "My Shoppe" }
67
71
  books/
@@ -94,16 +98,36 @@
94
98
  products/
95
99
  T-Shirt/ ← subfolder = physical product
96
100
  cover.jpg
97
- info.json ← { "title": "…", "description": "…", "price": 25, "shipping": 5 }</div>
101
+ info.json ← { "title": "…", "description": "…", "price": 25, "shipping": 5 }
102
+ appointments/
103
+ Office Hours/ ← subfolder = bookable appointment type
104
+ cover.jpg
105
+ info.json ← { "title": "…", "description": "…", "price": 0, "duration": 30,
106
+ "timezone": "America/Chicago", "advanceDays": 21,
107
+ "availability": [
108
+ { "day": "Monday", "slots": ["09:00", "10:00", "11:00"] },
109
+ { "day": "Friday", "slots": ["14:00", "15:00"] }
110
+ ] }
111
+ subscriptions/
112
+ Bronze Tier/ ← subfolder = infuse tier
113
+ cover.jpg
114
+ info.json ← { "title": "…", "description": "…", "price": 500,
115
+ "renewalDays": 30,
116
+ "benefits": ["Early access", "Monthly exclusive track"] }
117
+ bonus.mp3 ← any extra files become exclusive content for infusers</div>
98
118
  </div>
99
119
  </div>
100
120
  <div class="sw-step">
101
- <div class="sw-step-num">3</div>
102
- <div class="sw-step-body"><strong>Drag your .zip onto the upload zone below.</strong> Your goods will be registered and your shoppe will go live immediately.</div>
121
+ <div class="sw-step-num">4</div>
122
+ <div class="sw-step-body"><strong>Run <code>node shoppe-sign.js</code></strong> from inside your shoppe folder. This signs your manifest and creates an <code>upload.zip</code> next to the folder.</div>
103
123
  </div>
104
124
  <div class="sw-step">
105
- <div class="sw-step-num">4</div>
106
- <div class="sw-step-body"><strong>To update your shoppe</strong>, just rebuild your folder and upload a new archive existing items will be overwritten and new ones added.</div>
125
+ <div class="sw-step-num">5</div>
126
+ <div class="sw-step-body"><strong>Drag <code>upload.zip</code> onto the upload zone below.</strong> Your goods will be registered and your shoppe will go live immediately.</div>
127
+ </div>
128
+ <div class="sw-step">
129
+ <div class="sw-step-num">6</div>
130
+ <div class="sw-step-body"><strong>To update your shoppe</strong>, add content to your folder and run <code>node shoppe-sign.js</code> again — existing items are overwritten, new ones added.</div>
107
131
  </div>
108
132
  </div>
109
133
  </div>
@@ -113,7 +137,7 @@
113
137
  <h3>Upload your archive</h3>
114
138
  <div class="sw-drop" id="sw-drop">
115
139
  <div style="font-size:40px">📦</div>
116
- <p>Drag and drop your .zip here, or click to browse.<br>Your <code>manifest.json</code> must contain the <code>uuid</code> and <code>emojicode</code> you were given.</p>
140
+ <p>Drag and drop your signed <code>upload.zip</code> here, or click to browse.<br>Run <code>node shoppe-sign.js</code> in your shoppe folder to create it.</p>
117
141
  <button class="sw-btn sw-btn-blue" id="sw-browse-btn">Choose Archive</button>
118
142
  <input type="file" id="sw-file-input" accept=".zip" style="display:none">
119
143
  </div>
@@ -308,10 +332,14 @@
308
332
  if (!result.success) throw new Error(result.error || 'Registration failed');
309
333
 
310
334
  nameInput.value = '';
335
+ const bundleUrl = result.bundleToken ? `/plugin/shoppe/bundle/${result.bundleToken}` : null;
311
336
  showStatus(container, '#sw-register-status',
312
- `✅ Registered! Give these to the shoppe owner:<br>
313
- UUID: <code>${result.tenant.uuid}</code><br>
314
- Emojicode: <strong>${result.tenant.emojicode}</strong>`,
337
+ `✅ <strong>${result.tenant.name}</strong> registered!<br>
338
+ Emojicode: <strong>${result.tenant.emojicode}</strong><br><br>
339
+ ${bundleUrl
340
+ ? `<a href="${bundleUrl}" class="sw-btn sw-btn-green" style="display:inline-block;text-decoration:none;margin-bottom:8px;">⬇️ Download Starter Bundle</a><br>
341
+ <span style="font-size:12px;color:#555;">Hand this zip to the shoppe owner. The download link expires in 15 minutes and works only once.</span>`
342
+ : `UUID: <code>${result.tenant.uuid}</code>`}`,
315
343
  'success');
316
344
  loadDirectory(container);
317
345
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * shoppe-sign.js — Shoppe archive signing utility
6
+ *
7
+ * Commands:
8
+ * node shoppe-sign.js init First run: moves shoppe-key.json to ~/.shoppe/keys/
9
+ * and removes it from this directory.
10
+ *
11
+ * node shoppe-sign.js Signs manifest.json and creates a ready-to-upload zip.
12
+ *
13
+ * node shoppe-sign.js orders Generates a signed orders URL (opens in browser).
14
+ *
15
+ * node shoppe-sign.js payouts Opens Stripe Connect Express onboarding.
16
+ *
17
+ * Requires Node.js 16+ and sessionless-node (run `npm install` once).
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+ const { execSync } = require('child_process');
24
+
25
+ // ── Paths ────────────────────────────────────────────────────────────────────
26
+
27
+ const SHOPPE_DIR = __dirname;
28
+ const KEYS_DIR = path.join(os.homedir(), '.shoppe', 'keys');
29
+ const MANIFEST = path.join(SHOPPE_DIR, 'manifest.json');
30
+ const LOCAL_KEY = path.join(SHOPPE_DIR, 'shoppe-key.json');
31
+
32
+ // ── Helpers ──────────────────────────────────────────────────────────────────
33
+
34
+ function ensureDir(dir) {
35
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
36
+ }
37
+
38
+ function readManifest() {
39
+ if (!fs.existsSync(MANIFEST)) {
40
+ console.error('❌ manifest.json not found in:', SHOPPE_DIR);
41
+ process.exit(1);
42
+ }
43
+ try {
44
+ return JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
45
+ } catch (err) {
46
+ console.error('❌ manifest.json is not valid JSON:', err.message);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ function keyFilePath(uuid) {
52
+ return path.join(KEYS_DIR, `${uuid}.json`);
53
+ }
54
+
55
+ function loadStoredKey(uuid) {
56
+ const kp = keyFilePath(uuid);
57
+ if (!fs.existsSync(kp)) {
58
+ console.error('❌ No signing key found at:', kp);
59
+ console.error(' If this is a new shoppe run: node shoppe-sign.js init');
60
+ process.exit(1);
61
+ }
62
+ try {
63
+ return JSON.parse(fs.readFileSync(kp, 'utf8'));
64
+ } catch (err) {
65
+ console.error('❌ Key file is corrupted:', err.message);
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ // ── init — move key to secure storage ───────────────────────────────────────
71
+ // This command intentionally requires no npm install so it works immediately
72
+ // after unzipping the starter bundle.
73
+
74
+ function init() {
75
+ const manifest = readManifest();
76
+ const uuid = manifest.uuid;
77
+
78
+ if (!fs.existsSync(LOCAL_KEY)) {
79
+ const kp = keyFilePath(uuid);
80
+ if (fs.existsSync(kp)) {
81
+ console.log('✅ Already initialized. Your key is at:');
82
+ console.log(' ', kp);
83
+ console.log('\nWhenever you want to upload, run: node shoppe-sign.js');
84
+ } else {
85
+ console.error('❌ shoppe-key.json not found and no stored key exists.');
86
+ console.error(' Download a fresh starter bundle from your wiki.');
87
+ }
88
+ return;
89
+ }
90
+
91
+ let keyData;
92
+ try {
93
+ keyData = JSON.parse(fs.readFileSync(LOCAL_KEY, 'utf8'));
94
+ } catch (err) {
95
+ console.error('❌ shoppe-key.json is not valid JSON:', err.message);
96
+ process.exit(1);
97
+ }
98
+
99
+ if (!keyData.privateKey || !keyData.pubKey) {
100
+ console.error('❌ shoppe-key.json is missing privateKey or pubKey fields.');
101
+ process.exit(1);
102
+ }
103
+
104
+ ensureDir(KEYS_DIR);
105
+ const kp = keyFilePath(uuid);
106
+
107
+ // chmod 600 equivalent — silently ignored on Windows
108
+ fs.writeFileSync(kp, JSON.stringify(keyData, null, 2), { mode: 0o600 });
109
+ fs.unlinkSync(LOCAL_KEY);
110
+
111
+ console.log('✅ Key stored at:');
112
+ console.log(' ', kp);
113
+ console.log(' shoppe-key.json has been removed from this folder.\n');
114
+ console.log('Next steps:');
115
+ console.log(' npm install (one-time, installs sessionless-node)');
116
+ console.log(' node shoppe-sign.js (sign and zip whenever you want to upload)');
117
+ }
118
+
119
+ // ── sign — sign manifest and create upload zip ───────────────────────────────
120
+
121
+ async function sign() {
122
+ // Require sessionless-node — give a clear error if not yet installed
123
+ let sessionless;
124
+ try {
125
+ sessionless = require('sessionless-node');
126
+ } catch (err) {
127
+ console.error('❌ sessionless-node is not installed.');
128
+ console.error(' Run: npm install');
129
+ process.exit(1);
130
+ }
131
+
132
+ const manifest = readManifest();
133
+
134
+ if (!manifest.uuid) {
135
+ console.error('❌ manifest.json is missing uuid.');
136
+ process.exit(1);
137
+ }
138
+
139
+ if (fs.existsSync(LOCAL_KEY)) {
140
+ console.error('⚠️ shoppe-key.json is still in this folder.');
141
+ console.error(' Run node shoppe-sign.js init to store it securely first.');
142
+ process.exit(1);
143
+ }
144
+
145
+ const keyData = loadStoredKey(manifest.uuid);
146
+
147
+ // Sign with sessionless (secp256k1, message = timestamp + uuid)
148
+ const timestamp = Date.now().toString();
149
+ const message = timestamp + manifest.uuid;
150
+
151
+ sessionless.getKeys = () => ({ pubKey: keyData.pubKey, privateKey: keyData.privateKey });
152
+ const signature = await sessionless.sign(message);
153
+
154
+ // Strip any previous signature fields, then write fresh ones
155
+ const { ownerPubKey: _a, timestamp: _b, signature: _c, ...cleanManifest } = manifest;
156
+ const signedManifest = { ...cleanManifest, ownerPubKey: keyData.pubKey, timestamp, signature };
157
+
158
+ fs.writeFileSync(MANIFEST, JSON.stringify(signedManifest, null, 2));
159
+ console.log('✅ manifest.json signed.');
160
+
161
+ createZip();
162
+ }
163
+
164
+ // ── zip ──────────────────────────────────────────────────────────────────────
165
+
166
+ function createZip() {
167
+ // Place the zip *next to* the shoppe folder so it can't include itself
168
+ const folderName = path.basename(SHOPPE_DIR);
169
+ const parentDir = path.dirname(SHOPPE_DIR);
170
+ const outputZip = path.join(parentDir, `${folderName}-upload.zip`);
171
+
172
+ if (fs.existsSync(outputZip)) {
173
+ try { fs.unlinkSync(outputZip); } catch (_) {}
174
+ }
175
+
176
+ console.log('\n📦 Creating upload archive...');
177
+ try {
178
+ if (process.platform === 'win32') {
179
+ // Collect items to include (exclude shoppe-key.json if somehow still present)
180
+ const items = fs.readdirSync(SHOPPE_DIR)
181
+ .filter(f => f !== 'shoppe-key.json')
182
+ .map(f => `"${path.join(SHOPPE_DIR, f).replace(/"/g, '`"')}"`)
183
+ .join(',');
184
+ const psCmd = `Compress-Archive -Path @(${items}) -DestinationPath "${outputZip.replace(/\\/g, '\\\\')}" -Force`;
185
+ execSync(`powershell -NoProfile -Command "${psCmd}"`, { stdio: 'pipe' });
186
+ } else {
187
+ execSync(
188
+ `zip -r "${outputZip}" . -x "*/shoppe-key.json"`,
189
+ { cwd: SHOPPE_DIR, stdio: 'pipe' }
190
+ );
191
+ }
192
+ console.log(`✅ Created: ${path.basename(outputZip)}`);
193
+ console.log(` Location: ${outputZip}`);
194
+ console.log('\n Drag that file onto your wiki\'s shoppe plugin to upload.');
195
+ } catch (err) {
196
+ console.log('⚠️ Could not auto-create zip:', err.message);
197
+ console.log('\nZip this folder manually (excluding shoppe-key.json):');
198
+ if (process.platform !== 'win32') {
199
+ console.log(` cd "${parentDir}"`);
200
+ console.log(` zip -r "${path.basename(outputZip)}" "${folderName}" -x "*/shoppe-key.json"`);
201
+ } else {
202
+ console.log(' Right-click the folder in File Explorer → Send to → Compressed folder');
203
+ }
204
+ }
205
+ }
206
+
207
+ // ── orders — generate a signed orders URL ────────────────────────────────────
208
+
209
+ async function orders() {
210
+ let sessionless;
211
+ try {
212
+ sessionless = require('sessionless-node');
213
+ } catch (err) {
214
+ console.error('❌ sessionless-node is not installed.');
215
+ console.error(' Run: npm install');
216
+ process.exit(1);
217
+ }
218
+
219
+ const manifest = readManifest();
220
+
221
+ if (!manifest.uuid) {
222
+ console.error('❌ manifest.json is missing uuid.');
223
+ process.exit(1);
224
+ }
225
+
226
+ if (fs.existsSync(LOCAL_KEY)) {
227
+ console.error('⚠️ shoppe-key.json is still in this folder.');
228
+ console.error(' Run node shoppe-sign.js init first.');
229
+ process.exit(1);
230
+ }
231
+
232
+ const keyData = loadStoredKey(manifest.uuid);
233
+
234
+ const timestamp = Date.now().toString();
235
+ const message = timestamp + manifest.uuid;
236
+
237
+ sessionless.getKeys = () => ({ pubKey: keyData.pubKey, privateKey: keyData.privateKey });
238
+ const signature = await sessionless.sign(message);
239
+
240
+ // Determine base URL: manifest.wikiUrl, CLI argument, or just show the path
241
+ const wikiUrlArg = process.argv[3];
242
+ const baseUrl = wikiUrlArg
243
+ ? wikiUrlArg.replace(/\/+$/, '')
244
+ : manifest.wikiUrl
245
+ ? manifest.wikiUrl.replace(/\/orders.*$/, '') // strip any existing /orders path
246
+ : null;
247
+
248
+ const ordersPath = `/plugin/shoppe/${manifest.uuid}/orders?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`;
249
+ const fullUrl = baseUrl ? `${baseUrl}/orders?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}` : null;
250
+
251
+ console.log('\n🔑 Signed orders URL (valid for 5 minutes):\n');
252
+ if (fullUrl) {
253
+ console.log(' ' + fullUrl);
254
+ } else {
255
+ console.log(' Path: ' + ordersPath);
256
+ console.log('\n Prepend your wiki URL, e.g.:');
257
+ console.log(' https://mywiki.com' + ordersPath);
258
+ console.log('\n Or pass your wiki URL as an argument next time:');
259
+ console.log(' node shoppe-sign.js orders https://mywiki.com');
260
+ }
261
+
262
+ // Try to open in the default browser
263
+ if (fullUrl) {
264
+ console.log('\n Opening in browser...');
265
+ try {
266
+ const open = process.platform === 'win32' ? 'start' :
267
+ process.platform === 'darwin' ? 'open' : 'xdg-open';
268
+ execSync(`${open} "${fullUrl}"`, { stdio: 'ignore' });
269
+ } catch (_) {
270
+ // Browser open failed — URL is still printed above
271
+ }
272
+ }
273
+ console.log('');
274
+ }
275
+
276
+ // ── payouts — open Stripe Connect Express onboarding ─────────────────────────
277
+
278
+ async function payouts() {
279
+ let sessionless;
280
+ try {
281
+ sessionless = require('sessionless-node');
282
+ } catch (err) {
283
+ console.error('❌ sessionless-node is not installed.');
284
+ console.error(' Run: npm install');
285
+ process.exit(1);
286
+ }
287
+
288
+ const manifest = readManifest();
289
+
290
+ if (!manifest.uuid) {
291
+ console.error('❌ manifest.json is missing uuid.');
292
+ process.exit(1);
293
+ }
294
+
295
+ if (fs.existsSync(LOCAL_KEY)) {
296
+ console.error('⚠️ shoppe-key.json is still in this folder.');
297
+ console.error(' Run node shoppe-sign.js init first.');
298
+ process.exit(1);
299
+ }
300
+
301
+ const keyData = loadStoredKey(manifest.uuid);
302
+
303
+ const timestamp = Date.now().toString();
304
+ const message = timestamp + manifest.uuid;
305
+
306
+ sessionless.getKeys = () => ({ pubKey: keyData.pubKey, privateKey: keyData.privateKey });
307
+ const signature = await sessionless.sign(message);
308
+
309
+ // Determine base URL: manifest.wikiUrl, CLI argument, or just show the path
310
+ const wikiUrlArg = process.argv[3];
311
+ const baseUrl = wikiUrlArg
312
+ ? wikiUrlArg.replace(/\/+$/, '')
313
+ : manifest.wikiUrl
314
+ ? manifest.wikiUrl.replace(/\/payouts.*$/, '') // strip any existing /payouts path
315
+ : null;
316
+
317
+ const payoutsPath = `/plugin/shoppe/${manifest.uuid}/payouts?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`;
318
+ const fullUrl = baseUrl ? `${baseUrl}/payouts?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}` : null;
319
+
320
+ console.log('\n💳 Stripe Connect onboarding URL (valid for 5 minutes):\n');
321
+ if (fullUrl) {
322
+ console.log(' ' + fullUrl);
323
+ } else {
324
+ console.log(' Path: ' + payoutsPath);
325
+ console.log('\n Prepend your wiki URL, e.g.:');
326
+ console.log(' https://mywiki.com' + payoutsPath);
327
+ console.log('\n Or pass your wiki URL as an argument next time:');
328
+ console.log(' node shoppe-sign.js payouts https://mywiki.com');
329
+ }
330
+
331
+ // Try to open in the default browser
332
+ if (fullUrl) {
333
+ console.log('\n Opening in browser...');
334
+ try {
335
+ const open = process.platform === 'win32' ? 'start' :
336
+ process.platform === 'darwin' ? 'open' : 'xdg-open';
337
+ execSync(`${open} "${fullUrl}"`, { stdio: 'ignore' });
338
+ } catch (_) {
339
+ // Browser open failed — URL is still printed above
340
+ }
341
+ }
342
+ console.log('');
343
+ }
344
+
345
+ // ── main ─────────────────────────────────────────────────────────────────────
346
+
347
+ const command = process.argv[2];
348
+ if (command === 'init') {
349
+ init();
350
+ } else if (command === 'orders') {
351
+ orders().catch(err => {
352
+ console.error('❌ ', err.message);
353
+ process.exit(1);
354
+ });
355
+ } else if (command === 'payouts') {
356
+ payouts().catch(err => {
357
+ console.error('❌ ', err.message);
358
+ process.exit(1);
359
+ });
360
+ } else if (command === undefined) {
361
+ sign().catch(err => {
362
+ console.error('❌ ', err.message);
363
+ process.exit(1);
364
+ });
365
+ } else {
366
+ console.error(`Unknown command: ${command}`);
367
+ console.error('Usage: node shoppe-sign.js [init | orders [wiki-url] | payouts [wiki-url]]');
368
+ process.exit(1);
369
+ }