@blueprintitai/shop-os-install 0.5.15

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.
@@ -0,0 +1,1334 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shop OS Foundation installer.
4
+ *
5
+ * Single command flow for a paying customer:
6
+ * 1. Welcome + pre-flight (Node version, Claude Code present)
7
+ * 2. License key prompt
8
+ * 3. Validate against the live license server
9
+ * 4. Choose vault location
10
+ * 5. Add marketplaces (blueprint-skills + claude-plugins-official)
11
+ * 6. Install plugins (obsidian + superpowers)
12
+ * 7. Create vault directory
13
+ * 8. Enable plugins in <vault>/.claude/settings.json
14
+ * 9. Save license metadata to ~/.shopos/license.json
15
+ * 10. Print next steps
16
+ *
17
+ * Zero npm dependencies. Uses Node 18+ built-ins only.
18
+ */
19
+
20
+ import { createInterface } from "node:readline/promises";
21
+ import { stdin, stdout, stderr, exit } from "node:process";
22
+ import { homedir } from "node:os";
23
+ import { join, dirname, resolve, delimiter } from "node:path";
24
+ import { spawnSync } from "node:child_process";
25
+ import {
26
+ cpSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ readFileSync,
30
+ rmSync,
31
+ writeFileSync,
32
+ chmodSync,
33
+ } from "node:fs";
34
+
35
+ const LICENSE_SERVER = "https://shop-os-license-server.glenn-15d.workers.dev";
36
+ const SUPPORT_URL = "https://blueprintit.ai/shop-os/support";
37
+ const DOCS_URL = "https://blueprintit.ai/shop-os/docs";
38
+
39
+ // Read version from package.json so the user-agent never drifts from the
40
+ // published version. Falls back to "unknown" if the file can't be read.
41
+ let VERSION = "unknown";
42
+ try {
43
+ VERSION = JSON.parse(
44
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
45
+ ).version;
46
+ } catch { /* keep "unknown" */ }
47
+
48
+ const MARKETPLACES = [
49
+ {
50
+ name: "blueprint-skills",
51
+ source: { type: "github", repo: "blueprintit-ai/blueprint-skills" },
52
+ },
53
+ {
54
+ name: "claude-plugins-official",
55
+ source: { type: "github", repo: "anthropics/claude-plugins-official" },
56
+ },
57
+ ];
58
+
59
+ const PLUGINS_TO_ENABLE = [
60
+ "obsidian@blueprint-skills",
61
+ "superpowers@claude-plugins-official",
62
+ ];
63
+
64
+ // ---------- output helpers ----------
65
+
66
+ const SUPPORTS_COLOR = stdout.isTTY && !process.env.NO_COLOR;
67
+ const c = (code, s) => (SUPPORTS_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
68
+ const dim = (s) => c("2", s);
69
+ const bold = (s) => c("1", s);
70
+ const green = (s) => c("32", s);
71
+ const yellow = (s) => c("33", s);
72
+ const red = (s) => c("31", s);
73
+ const cyan = (s) => c("36", s);
74
+
75
+ const print = (msg = "") => stdout.write(msg + "\n");
76
+ const warn = (msg) => stderr.write(yellow("! ") + msg + "\n");
77
+ // fail() throws instead of exiting so every failure funnels through main()'s
78
+ // catch, which logs telemetry before exiting. `handled` marks an expected,
79
+ // already-worded failure (vs. an unexpected crash) so the catch doesn't prefix
80
+ // it with "Unexpected error:".
81
+ class InstallError extends Error {
82
+ constructor(msg) {
83
+ super(msg);
84
+ this.handled = true;
85
+ }
86
+ }
87
+ const fail = (msg) => {
88
+ throw new InstallError(msg);
89
+ };
90
+ const ok = (msg) => print(" " + green("✓") + " " + msg);
91
+ const info = (msg) => print(" " + dim("·") + " " + msg);
92
+
93
+ // ---------- telemetry + resilience ----------
94
+
95
+ // Updated as the install progresses so an error log pinpoints where it broke.
96
+ // Mirrors the step names the PowerShell/bash wrappers send to /install-log, so
97
+ // the admin install-logs view reads consistently across the whole funnel.
98
+ let currentStep = "preflight";
99
+ let currentLicenseKey = "unknown";
100
+
101
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
102
+
103
+ // Best-effort install telemetry. Never throws and never blocks the install for
104
+ // more than ~5s — the wrappers log the outer steps, this fills in the npx-internal
105
+ // steps that were previously invisible (license rejection, clone failure, etc.).
106
+ async function sendInstallLog(status, errorMessage) {
107
+ try {
108
+ const controller = new AbortController();
109
+ const timer = setTimeout(() => controller.abort(), 5000);
110
+ const payload = {
111
+ license_key: currentLicenseKey || "unknown",
112
+ status,
113
+ step: currentStep,
114
+ // The endpoint stores `machine` verbatim; tuck source/version here so they
115
+ // persist (top-level extras are dropped server-side).
116
+ machine: {
117
+ platform: process.platform,
118
+ node: process.version,
119
+ source: "npx",
120
+ version: VERSION,
121
+ },
122
+ };
123
+ if (errorMessage) payload.error_message = String(errorMessage).slice(0, 1000);
124
+ await fetch(`${LICENSE_SERVER}/install-log`, {
125
+ method: "POST",
126
+ headers: {
127
+ "content-type": "application/json",
128
+ "user-agent": `shop-os-installer/${VERSION}`,
129
+ },
130
+ body: JSON.stringify(payload),
131
+ signal: controller.signal,
132
+ });
133
+ clearTimeout(timer);
134
+ } catch {
135
+ // best-effort: telemetry must never affect the install outcome
136
+ }
137
+ }
138
+
139
+ // Retry an async operation that may fail on a transient network blip. Definitive
140
+ // failures (caller throws after the last attempt) propagate to the caller.
141
+ async function withRetry(fn, { attempts = 2, delayMs = 1500, label = "Operation" } = {}) {
142
+ let lastErr;
143
+ for (let i = 1; i <= attempts; i++) {
144
+ try {
145
+ return await fn();
146
+ } catch (e) {
147
+ lastErr = e;
148
+ if (i < attempts) {
149
+ warn(`${label} failed (attempt ${i}/${attempts}) — retrying in ${Math.round(delayMs / 1000)}s...`);
150
+ await sleep(delayMs);
151
+ }
152
+ }
153
+ }
154
+ throw lastErr;
155
+ }
156
+
157
+ function banner() {
158
+ const lines = [
159
+ "",
160
+ bold(" ╔════════════════════════════════════════════════════════════╗"),
161
+ bold(" ║ ║"),
162
+ bold(" ║ ") + cyan("Shop OS Foundation Installer") + bold(" ║"),
163
+ bold(" ║ ") + dim("AI Operating System for Small Businesses") + bold(" ║"),
164
+ bold(" ║ ║"),
165
+ bold(" ╚════════════════════════════════════════════════════════════╝"),
166
+ "",
167
+ ];
168
+ lines.forEach((l) => print(l));
169
+ }
170
+
171
+ // ---------- prompts ----------
172
+
173
+ async function ask(rl, question, { default: dflt } = {}) {
174
+ const prompt = dflt
175
+ ? `${cyan("?")} ${question} ${dim(`[${dflt}]`)}: `
176
+ : `${cyan("?")} ${question}: `;
177
+ const ans = (await rl.question(prompt)).trim();
178
+ return ans || dflt || "";
179
+ }
180
+
181
+ async function confirm(rl, question, { default: dflt = true } = {}) {
182
+ const hint = dflt ? "Y/n" : "y/N";
183
+ const ans = (await rl.question(`${cyan("?")} ${question} ${dim(`[${hint}]`)}: `))
184
+ .trim()
185
+ .toLowerCase();
186
+ if (!ans) return dflt;
187
+ return ans === "y" || ans === "yes";
188
+ }
189
+
190
+ // ---------- preflight ----------
191
+
192
+ function checkNode() {
193
+ const major = Number(process.versions.node.split(".")[0]);
194
+ if (major < 18) {
195
+ fail(`Node.js 18+ required. You have ${process.version}.`);
196
+ }
197
+ return process.version;
198
+ }
199
+
200
+ function getClaudeRoot() {
201
+ return join(homedir(), ".claude");
202
+ }
203
+
204
+ // Probe a host for reachability. ANY HTTP response (even 404/403) means the host
205
+ // is reachable — DNS, TCP, and TLS all worked. Only a thrown error (offline,
206
+ // DNS failure, blocked by firewall/proxy, timeout) counts as unreachable.
207
+ async function isReachable(url) {
208
+ try {
209
+ const controller = new AbortController();
210
+ const timer = setTimeout(() => controller.abort(), 8000);
211
+ await fetch(url, { method: "GET", redirect: "manual", signal: controller.signal });
212
+ clearTimeout(timer);
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ // Catch offline / proxy / firewall problems up front with a clear message, rather
220
+ // than letting them surface mid-install as a cryptic git-clone or fetch failure.
221
+ // Shops behind corporate/guest networks frequently block raw GitHub access.
222
+ async function checkConnectivity() {
223
+ currentStep = "connectivity_check";
224
+ const targets = [
225
+ { name: "GitHub (github.com)", url: "https://github.com" },
226
+ { name: "Shop OS license server", url: LICENSE_SERVER },
227
+ ];
228
+ for (const t of targets) {
229
+ if (!(await isReachable(t.url))) {
230
+ fail(
231
+ `Can't reach ${t.name}.\n\n` +
232
+ ` Shop OS needs internet access to ${t.url}\n\n` +
233
+ ` Check your Wi-Fi/connection. If you're on a work or guest network,\n` +
234
+ ` it may block GitHub — try a home/phone hotspot, or ask IT to allow\n` +
235
+ ` github.com and *.workers.dev. Then re-run the installer.`,
236
+ );
237
+ }
238
+ }
239
+ ok("Internet connectivity confirmed");
240
+ }
241
+
242
+ function checkClaudeCode() {
243
+ // Detect by binary on PATH, not by the ~/.claude directory: when Claude Code
244
+ // is installed via `npm install -g @anthropic-ai/claude-code` (which the
245
+ // setup scripts now do), the .claude directory isn't created until the user
246
+ // launches `claude` for the first time. A binary check correctly identifies
247
+ // installs from npm, the official .ps1/.sh installer, or the desktop app.
248
+ //
249
+ // The native installer drops `claude` in ~/.local/bin, which a fresh shell may
250
+ // not have on PATH yet. Prepend it before probing so a real install is never
251
+ // mistaken for missing (the exact failure that stalled early installs).
252
+ const localBin = join(homedir(), ".local", "bin");
253
+ if (existsSync(localBin) && !(process.env.PATH || "").split(delimiter).includes(localBin)) {
254
+ process.env.PATH = localBin + delimiter + (process.env.PATH || "");
255
+ }
256
+ currentStep = "claude_check";
257
+ const probe = spawnSync(
258
+ process.platform === "win32" ? "where" : "which",
259
+ ["claude"],
260
+ { stdio: "ignore", shell: false },
261
+ );
262
+ if (probe.status === 0) {
263
+ ok("Claude Code found");
264
+ } else {
265
+ // Claude Code not in PATH. Try auto-installing via npm.
266
+ // shell: true is REQUIRED on Windows — npm is npm.cmd (a batch file), and
267
+ // spawnSync can't execute it without a shell (ENOENT otherwise). stdio
268
+ // inherit so the customer sees npm's progress instead of a frozen prompt.
269
+ currentStep = "claude_autoinstall";
270
+ print("");
271
+ print(yellow("Claude Code not found. Auto-installing via npm..."));
272
+ const npmInstall = spawnSync("npm", ["install", "-g", "@anthropic-ai/claude-code"], {
273
+ stdio: "inherit",
274
+ shell: true,
275
+ });
276
+ if (npmInstall.status !== 0) {
277
+ fail(
278
+ "Claude Code auto-install failed.\n\n" +
279
+ " Shop OS runs on top of Claude Code. Install it manually at:\n" +
280
+ " https://claude.ai/code\n\n" +
281
+ " Then re-run this installer.",
282
+ );
283
+ }
284
+ // Verify the install by checking PATH again (may need a refresh on Windows).
285
+ const verify = spawnSync(
286
+ process.platform === "win32" ? "where" : "which",
287
+ ["claude"],
288
+ { stdio: "ignore", shell: false },
289
+ );
290
+ if (verify.status !== 0) {
291
+ fail(
292
+ "Claude Code installed but 'claude' is not on PATH yet.\n\n" +
293
+ " This is a PATH refresh issue. Please:\n" +
294
+ " 1. Close this terminal\n" +
295
+ " 2. Open a new terminal\n" +
296
+ " 3. Re-run the installer",
297
+ );
298
+ }
299
+ ok("Claude Code installed and verified");
300
+ }
301
+ // Stage ~/.claude so downstream marketplace and plugin writes succeed even
302
+ // if the user hasn't launched `claude` yet to seed the dir themselves.
303
+ const root = getClaudeRoot();
304
+ if (!existsSync(root)) {
305
+ mkdirSync(root, { recursive: true });
306
+ }
307
+ return root;
308
+ }
309
+
310
+ // ---------- license validation ----------
311
+
312
+ // A Shop OS key is SHOP-XXXX-XXXX-XXXX (Crockford Base32). Normalize common paste
313
+ // artifacts (lowercase, stray spaces, surrounding whitespace) so a perfectly valid
314
+ // key isn't rejected over formatting, and shape-check for instant feedback on an
315
+ // obvious typo. The license server stays authoritative — a shape miss never
316
+ // hard-blocks an automated --license run.
317
+ function normalizeLicenseKey(raw) {
318
+ return String(raw || "").trim().toUpperCase().replace(/\s+/g, "");
319
+ }
320
+
321
+ const LICENSE_KEY_SHAPE = /^SHOP-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
322
+
323
+ function looksLikeLicenseKey(key) {
324
+ return LICENSE_KEY_SHAPE.test(key);
325
+ }
326
+
327
+ async function validateLicense(key) {
328
+ const url = `${LICENSE_SERVER}/validate?key=${encodeURIComponent(key)}`;
329
+ let resp;
330
+ try {
331
+ // Retry only the network call — a transient blip shouldn't reject a valid
332
+ // key. An HTTP error response (handled below) is a definitive answer and is
333
+ // NOT retried.
334
+ resp = await withRetry(
335
+ () => fetch(url, { headers: { "user-agent": `shop-os-installer/${VERSION}` } }),
336
+ { attempts: 3, delayMs: 1500, label: "License server request" },
337
+ );
338
+ } catch (e) {
339
+ return { ok: false, error: `network: ${e.message}` };
340
+ }
341
+ const text = await resp.text();
342
+ let body;
343
+ try {
344
+ body = JSON.parse(text);
345
+ } catch {
346
+ return { ok: false, error: `unexpected response (HTTP ${resp.status})` };
347
+ }
348
+ if (!resp.ok) {
349
+ return { ok: false, error: body.error || `HTTP ${resp.status}` };
350
+ }
351
+ return { ok: true, license: body };
352
+ }
353
+
354
+ // ---------- claude code config ----------
355
+
356
+ function readJSON(path, fallback) {
357
+ if (!existsSync(path)) return fallback;
358
+ try {
359
+ return JSON.parse(readFileSync(path, "utf8"));
360
+ } catch {
361
+ return fallback;
362
+ }
363
+ }
364
+
365
+ function writeJSON(path, obj) {
366
+ mkdirSync(dirname(path), { recursive: true });
367
+ writeFileSync(path, JSON.stringify(obj, null, 2) + "\n", "utf8");
368
+ }
369
+
370
+ async function ensureMarketplaces(claudeRoot) {
371
+ // Always (re-)register marketplaces and refresh the on-disk clone to the
372
+ // latest origin/main. Claude Code does NOT auto-pull marketplace clones, so
373
+ // a clone left at an old commit (e.g. the 5/20 rebrand snapshot) keeps
374
+ // serving stale plugins forever. We use git directly to refresh — pull when
375
+ // possible, fall back to wipe-and-re-clone if pull fails or the directory
376
+ // isn't a git repo.
377
+ //
378
+ // We still report `added` vs already-known to keep the customer-facing
379
+ // install output consistent across runs — the refresh is intentionally
380
+ // invisible.
381
+ const path = join(claudeRoot, "plugins", "known_marketplaces.json");
382
+ const known = readJSON(path, {});
383
+ const added = [];
384
+ const failed = [];
385
+ for (const mp of MARKETPLACES) {
386
+ const installLocation = join(claudeRoot, "plugins", "marketplaces", mp.name);
387
+ if (!known[mp.name]) added.push(mp.name);
388
+ known[mp.name] = {
389
+ source: { source: mp.source.type, repo: mp.source.repo },
390
+ installLocation,
391
+ lastUpdated: new Date().toISOString(),
392
+ };
393
+ const success = await refreshMarketplaceClone(mp, installLocation);
394
+ if (!success) failed.push(mp);
395
+ }
396
+ writeJSON(path, known);
397
+
398
+ // A failed clone is the difference between a working vault and a "successful"
399
+ // install where /bp-setup and the obsidian plugin simply don't exist. Fail
400
+ // loudly instead of reporting a false success.
401
+ if (failed.length > 0) {
402
+ const names = failed.map((m) => m.source.repo).join(", ");
403
+ fail(
404
+ `Could not download the Shop OS skills from GitHub (${names}).\n\n` +
405
+ ` The repository failed to clone after retries — usually a dropped\n` +
406
+ ` connection, or a network that blocks github.com.\n\n` +
407
+ ` Check your connection (a work/guest network may block GitHub — try a\n` +
408
+ ` home network or phone hotspot) and re-run the installer. Nothing was\n` +
409
+ ` finalized, so re-running is safe.`,
410
+ );
411
+ }
412
+ return { added, total: MARKETPLACES.length };
413
+ }
414
+
415
+ // Returns true if the marketplace clone is present and up to date, false if it
416
+ // could not be obtained. Never throws — the caller decides whether a miss is fatal.
417
+ async function refreshMarketplaceClone(mp, installLocation) {
418
+ // GitHub HTTPS URL — same form Claude Code uses internally.
419
+ const repoUrl = `https://github.com/${mp.source.repo}.git`;
420
+
421
+ // If a git checkout exists, fast-forward it to origin/main.
422
+ if (existsSync(join(installLocation, ".git"))) {
423
+ const fetched = spawnSync("git", ["fetch", "origin", "main", "--depth=1"], {
424
+ cwd: installLocation,
425
+ stdio: "ignore",
426
+ });
427
+ if (fetched.status === 0) {
428
+ const reset = spawnSync("git", ["reset", "--hard", "FETCH_HEAD"], {
429
+ cwd: installLocation,
430
+ stdio: "ignore",
431
+ });
432
+ if (reset.status === 0) return true;
433
+ }
434
+ // Fetch/reset failed — fall through to wipe + fresh clone.
435
+ }
436
+
437
+ // Wipe anything in the install location before cloning fresh. Best-effort:
438
+ // locked files on Windows may block removal, in which case clone below will
439
+ // also fail and we report that to the caller.
440
+ if (existsSync(installLocation)) {
441
+ try {
442
+ rmSync(installLocation, { recursive: true, force: true });
443
+ } catch {
444
+ // intentional fall-through
445
+ }
446
+ }
447
+
448
+ mkdirSync(dirname(installLocation), { recursive: true });
449
+
450
+ // Clone with one retry on a transient failure. A clone is "successful" only if
451
+ // git exits 0 AND the .git directory actually landed — guards against a partial
452
+ // clone that exits non-zero but leaves a husk directory behind.
453
+ try {
454
+ await withRetry(
455
+ () => {
456
+ const clone = spawnSync("git", ["clone", "--depth=1", repoUrl, installLocation], {
457
+ stdio: "ignore",
458
+ });
459
+ if (clone.status !== 0 || !existsSync(join(installLocation, ".git"))) {
460
+ // Clean up a partial clone so the retry starts fresh.
461
+ try { rmSync(installLocation, { recursive: true, force: true }); } catch { /* best-effort */ }
462
+ throw new Error(`git clone exited ${clone.status}`);
463
+ }
464
+ return true;
465
+ },
466
+ { attempts: 2, delayMs: 2000, label: `Cloning ${mp.source.repo}` },
467
+ );
468
+ return true;
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
473
+
474
+ // Post-install smoke test. A landed clone (guaranteed by ensureMarketplaces) is
475
+ // not the same as the manifest actually listing the plugin Claude Code resolves
476
+ // on launch. This catches a download that succeeded but doesn't contain the
477
+ // plugin behind /bp-setup — so the customer learns now, not when a slash command
478
+ // is mysteriously missing. Returns a list of problems ([] = all good).
479
+ function verifyInstall(claudeRoot) {
480
+ const problems = [];
481
+ for (const id of PLUGINS_TO_ENABLE) {
482
+ const [pluginName, marketplaceName] = id.split("@");
483
+ if (!pluginName || !marketplaceName) continue;
484
+ const manifestPath = join(
485
+ claudeRoot,
486
+ "plugins",
487
+ "marketplaces",
488
+ marketplaceName,
489
+ ".claude-plugin",
490
+ "marketplace.json",
491
+ );
492
+ if (!existsSync(manifestPath)) {
493
+ // Non-fatal: clone landed but manifest absent (unexpected layout) — Claude
494
+ // Code may still cope, so warn rather than block.
495
+ problems.push({ id, reason: `manifest missing in ${marketplaceName}`, fatal: false });
496
+ continue;
497
+ }
498
+ let manifest;
499
+ try {
500
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
501
+ } catch {
502
+ problems.push({ id, reason: `manifest in ${marketplaceName} could not be read`, fatal: false });
503
+ continue;
504
+ }
505
+ const names = Array.isArray(manifest.plugins)
506
+ ? manifest.plugins.map((p) => p && p.name)
507
+ : [];
508
+ if (!names.includes(pluginName)) {
509
+ // Fatal: the manifest parsed fine and definitively does not list the plugin.
510
+ problems.push({
511
+ id,
512
+ reason: `plugin "${pluginName}" not found in the ${marketplaceName} marketplace`,
513
+ fatal: true,
514
+ });
515
+ }
516
+ }
517
+ return problems;
518
+ }
519
+
520
+ function ensurePluginsInstalled(claudeRoot, vaultPath) {
521
+ // Always reset and directly install the Shop OS-required plugins from the
522
+ // (just-refreshed) marketplace clones. Previous versions wrote a
523
+ // "version: pending / installPath: null" stub and assumed Claude Code would
524
+ // sync the files on next launch — but Claude Code does not resolve pending
525
+ // entries on startup, so skills were silently missing on new installs.
526
+ //
527
+ // We now copy the plugin directory from the marketplace clone straight into
528
+ // the plugin cache and write the real installPath + version. If the copy
529
+ // fails for any reason (locked file, missing source) we fall back to the
530
+ // pending stub so the install still completes.
531
+ //
532
+ // The entry is written at project scope and pinned to this vault, so the Shop
533
+ // OS plugin set is active in the customer's vault without being enabled in
534
+ // every unrelated project they happen to open Claude Code in. Any previous
535
+ // entry for these ids is replaced outright.
536
+ const path = join(claudeRoot, "plugins", "installed_plugins.json");
537
+ const existing = readJSON(path, { version: 2, plugins: {} });
538
+ if (!existing.plugins) existing.plugins = {};
539
+ const installedAt = new Date().toISOString();
540
+ const queued = [];
541
+ const pending = [];
542
+ const installedViaCli = [];
543
+
544
+ for (const id of PLUGINS_TO_ENABLE) {
545
+ if (!existing.plugins[id]) queued.push(id);
546
+
547
+ const [pluginName, marketplaceName] = id.split("@");
548
+
549
+ // Wipe the per-version cache so we don't load a stale pinned version.
550
+ if (pluginName && marketplaceName) {
551
+ const pluginCacheDir = join(claudeRoot, "plugins", "cache", marketplaceName, pluginName);
552
+ if (existsSync(pluginCacheDir)) {
553
+ try {
554
+ rmSync(pluginCacheDir, { recursive: true, force: true });
555
+ } catch {
556
+ // Best-effort: locked file on Windows may leave the cache in place.
557
+ }
558
+ }
559
+ }
560
+
561
+ // Try to install the plugin directly from the marketplace clone so it is
562
+ // immediately usable when Claude Code starts (no sync required).
563
+ let installed = false;
564
+ if (pluginName && marketplaceName) {
565
+ const marketplaceDir = join(claudeRoot, "plugins", "marketplaces", marketplaceName);
566
+ const pluginSourceDir = join(marketplaceDir, "plugins", pluginName);
567
+
568
+ if (existsSync(join(pluginSourceDir, ".claude-plugin"))) {
569
+ // Read version from the plugin manifest.
570
+ let version = "unknown";
571
+ try {
572
+ const pluginJson = JSON.parse(
573
+ readFileSync(join(pluginSourceDir, ".claude-plugin", "plugin.json"), "utf8"),
574
+ );
575
+ version = pluginJson.version || "unknown";
576
+ } catch { /* keep "unknown" */ }
577
+
578
+ // Resolve the HEAD commit SHA of the marketplace clone.
579
+ let gitCommitSha = "pending-sync";
580
+ const gitResult = spawnSync("git", ["rev-parse", "HEAD"], {
581
+ cwd: marketplaceDir,
582
+ stdio: ["ignore", "pipe", "ignore"],
583
+ encoding: "utf8",
584
+ });
585
+ if (gitResult.status === 0 && gitResult.stdout) {
586
+ gitCommitSha = gitResult.stdout.trim();
587
+ }
588
+
589
+ // Copy plugin files from the marketplace clone into the versioned cache.
590
+ const installPath = join(
591
+ claudeRoot, "plugins", "cache", marketplaceName, pluginName, version,
592
+ );
593
+ try {
594
+ mkdirSync(join(claudeRoot, "plugins", "cache", marketplaceName, pluginName), {
595
+ recursive: true,
596
+ });
597
+ cpSync(pluginSourceDir, installPath, { recursive: true });
598
+
599
+ // User scope, not project scope: project-scoped plugins only load when
600
+ // the session's working directory is exactly the vault, which breaks
601
+ // IDE extensions (VS Code, Antigravity, Cursor) and any terminal that
602
+ // wasn't cd'd into the vault first. Shop OS machines are single-purpose
603
+ // enough that machine-wide availability is the right default.
604
+ existing.plugins[id] = [
605
+ {
606
+ scope: "user",
607
+ installPath,
608
+ version,
609
+ installedAt,
610
+ lastUpdated: installedAt,
611
+ gitCommitSha,
612
+ },
613
+ ];
614
+ installed = true;
615
+ } catch {
616
+ // Fall through to pending stub — install still completes.
617
+ }
618
+ }
619
+ }
620
+
621
+ if (!installed) pending.push(id);
622
+ }
623
+
624
+ writeJSON(path, existing);
625
+
626
+ // Plugins that aren't shipped as a directory inside the marketplace clone
627
+ // (superpowers is sourced from an external git URL) can't be copied. Hand
628
+ // those to Claude Code's own CLI, which resolves the source, populates the
629
+ // cache and records the entry itself. Claude Code DELETES a "pending" stub
630
+ // on launch rather than resolving it, so this is the only path that actually
631
+ // lands the skills. User scope so the plugins work in every session on the
632
+ // machine (see above). Works without being signed in.
633
+ const stillPending = [];
634
+ for (const id of pending) {
635
+ const cli = spawnSync("claude", ["plugin", "install", id, "--scope", "user"], {
636
+ cwd: vaultPath,
637
+ stdio: "ignore",
638
+ shell: process.platform === "win32",
639
+ timeout: 180000,
640
+ });
641
+ const after = readJSON(path, { plugins: {} });
642
+ const entry = Array.isArray(after.plugins?.[id]) ? after.plugins[id][0] : null;
643
+ if (cli.status === 0 && entry && entry.installPath && existsSync(entry.installPath)) {
644
+ installedViaCli.push(id);
645
+ } else {
646
+ stillPending.push(id);
647
+ }
648
+ }
649
+ return { queued, installedViaCli, pending: stillPending, total: PLUGINS_TO_ENABLE.length };
650
+ }
651
+
652
+ // Vault-scoped permission allowlist. Non-technical Shop OS customers were hitting
653
+ // constant tool-permission prompts during /bp-setup (~50+ Read/Write/Bash dialogs
654
+ // per onboarding run). These patterns pre-approve the tool surface bp-setup and
655
+ // the daily Shop OS flow actually use, scoped to this vault's project settings —
656
+ // not the user's global settings. Risk model: customer's own paid Claude
657
+ // subscription, own machine, own data; we trade some inbound-injection surface
658
+ // for a workable UX. Writes/edits are intentionally bounded to the vault path.
659
+ function buildPermissionAllowList(vaultPath) {
660
+ return [
661
+ // Read-side: bp-setup reads reference templates from the marketplace clone
662
+ // (~/.claude/plugins/...) AND any context files the user drops in. Read,
663
+ // Glob, and Grep are low-risk; allow them broadly.
664
+ "Read",
665
+ "Glob",
666
+ "Grep",
667
+ // Write-side: scope to the vault directory so Claude can't be coaxed into
668
+ // writing outside it. A single leading slash is relative to the settings
669
+ // source (this vault's .claude/settings.json), which is exactly the vault
670
+ // root, and it works on Windows too. An embedded absolute path does NOT:
671
+ // Claude Code treats "/Users/x/vault/**" as vault-relative (needs "//"),
672
+ // and normalizes "C:\..." to "/c/..." before matching.
673
+ "Write(/**)",
674
+ "Edit(/**)",
675
+ // Bash: bp-setup creates directories, chmods hooks, finds reference files.
676
+ // Restrict to the specific subcommands the skill actually invokes.
677
+ "Bash(mkdir:*)",
678
+ "Bash(chmod:*)",
679
+ "Bash(find:*)",
680
+ "Bash(ls:*)",
681
+ "Bash(cat:*)",
682
+ "Bash(grep:*)",
683
+ "Bash(echo:*)",
684
+ "Bash(test:*)",
685
+ "Bash(touch:*)",
686
+ // Network: bp-setup's Phase B+ fetches links the user pastes (LinkedIn,
687
+ // About pages, brand guidelines, etc.).
688
+ "WebFetch",
689
+ "WebSearch",
690
+ // Plan tool: surfaced by some Superpowers skills, no need to prompt.
691
+ "TodoWrite",
692
+ ];
693
+ }
694
+
695
+ // Enable the Shop OS plugins in the user-level settings so every Claude Code
696
+ // session on the machine gets the /bp commands: terminal in any directory, and
697
+ // IDE extensions (VS Code, Antigravity, Cursor) whose sessions don't start in
698
+ // the vault. Merges; never removes anything already enabled.
699
+ function enableForUser(claudeRoot) {
700
+ const settingsPath = join(claudeRoot, "settings.json");
701
+ const settings = readJSON(settingsPath, {});
702
+ if (!settings.enabledPlugins) settings.enabledPlugins = {};
703
+ for (const id of PLUGINS_TO_ENABLE) {
704
+ settings.enabledPlugins[id] = true;
705
+ }
706
+ writeJSON(settingsPath, settings);
707
+ return settingsPath;
708
+ }
709
+
710
+ function enableForVault(vaultPath) {
711
+ const settingsPath = join(vaultPath, ".claude", "settings.json");
712
+ const settings = readJSON(settingsPath, {});
713
+ if (!settings.enabledPlugins) settings.enabledPlugins = {};
714
+ for (const id of PLUGINS_TO_ENABLE) {
715
+ settings.enabledPlugins[id] = true;
716
+ }
717
+ // Merge (don't clobber) any existing permission allowlist a re-install would
718
+ // have written, then re-add ours. De-dupe by entry string.
719
+ if (!settings.permissions) settings.permissions = {};
720
+ const existing = Array.isArray(settings.permissions.allow)
721
+ ? settings.permissions.allow
722
+ : [];
723
+ const ours = buildPermissionAllowList(vaultPath);
724
+ settings.permissions.allow = Array.from(new Set([...existing, ...ours]));
725
+ writeJSON(settingsPath, settings);
726
+ return settingsPath;
727
+ }
728
+
729
+ // Pre-empt Claude Code's first-run wizard (theme picker, terminal-setup prompt,
730
+ // onboarding screens) by writing the "already onboarded" flags BEFORE the
731
+ // setup script launches `claude`. The auth/sign-in flow is independent and
732
+ // still happens — we can't bypass that and don't try. Safe to run repeatedly:
733
+ // merges with any existing config rather than overwriting.
734
+ function seedClaudeCodeDefaults(claudeRoot) {
735
+ const seeded = { onboardingFlags: false, theme: false };
736
+
737
+ // ~/.claude.json holds Claude Code's user-state (numStartups, hasCompletedOnboarding,
738
+ // anonymousId, etc.). Live next to ~/.claude/, not inside it.
739
+ const userStatePath = join(homedir(), ".claude.json");
740
+ const userState = readJSON(userStatePath, {});
741
+ if (!userState.hasCompletedOnboarding) {
742
+ userState.hasCompletedOnboarding = true;
743
+ if (!userState.lastOnboardingVersion) userState.lastOnboardingVersion = "1.0.30";
744
+ seeded.onboardingFlags = true;
745
+ try {
746
+ writeFileSync(userStatePath, JSON.stringify(userState, null, 2) + "\n", "utf8");
747
+ } catch {
748
+ // best-effort; permission errors here aren't worth blocking the install
749
+ }
750
+ }
751
+
752
+ // ~/.claude/settings.json holds user-scope settings (theme, permissions, etc.)
753
+ const settingsPath = join(claudeRoot, "settings.json");
754
+ const settings = readJSON(settingsPath, {});
755
+ if (!settings.theme) {
756
+ settings.theme = "dark";
757
+ seeded.theme = true;
758
+ try {
759
+ writeJSON(settingsPath, settings);
760
+ } catch {
761
+ // best-effort
762
+ }
763
+ }
764
+
765
+ return seeded;
766
+ }
767
+
768
+ function createVaultClaudeMd(vaultPath, license) {
769
+ const claudeMd = join(vaultPath, "CLAUDE.md");
770
+ if (existsSync(claudeMd)) return false; // do not overwrite an existing vault
771
+ const content = `---
772
+ os-mode: business
773
+ bp-setup-state: pending
774
+ license-customer: ${license.customer}
775
+ license-product: ${license.product}
776
+ installed-at: ${new Date().toISOString()}
777
+ ---
778
+
779
+ # Shop OS Vault
780
+
781
+ Welcome to your Shop OS vault. This is the operating system Blueprint IT installed for ${license.customer}.
782
+
783
+ To finish onboarding, run the following slash command inside Claude Code:
784
+
785
+ \`/bp-setup\`
786
+
787
+ This walks you through personalizing the vault for your shop: name, owner, key staff,
788
+ services, daily routines, and more.
789
+
790
+ For help, see ${DOCS_URL}
791
+ or reply to your welcome email.
792
+ `;
793
+ mkdirSync(vaultPath, { recursive: true });
794
+ writeFileSync(claudeMd, content, "utf8");
795
+ return true;
796
+ }
797
+
798
+ function createRawInbox(vaultPath) {
799
+ // Create a flat Raw/ inbox with a processed/ subfolder for after-digest moves.
800
+ // Customers drop any raw materials in Raw/ — no subfolders to think about.
801
+ // Claude Code reads, classifies, routes into the vault, and moves the
802
+ // source file to Raw/processed/.
803
+ const rawDir = join(vaultPath, "Raw");
804
+ const processedDir = join(rawDir, "processed");
805
+ const readmePath = join(rawDir, "README.md");
806
+
807
+ const existed = existsSync(rawDir);
808
+ mkdirSync(processedDir, { recursive: true });
809
+
810
+ if (existsSync(readmePath)) return { created: false };
811
+
812
+ const readme = `---
813
+ type: inbox-readme
814
+ tags: [shop-os, inbox, raw]
815
+ ---
816
+
817
+ # Raw / Inbox
818
+
819
+ Drop any raw materials here that you want Shop OS to read and route into your vault.
820
+ PDFs, photos, transcripts, contracts, price lists, spreadsheets, scans, anything.
821
+
822
+ You do NOT need to organize them into subfolders. Just drop them flat. Claude Code
823
+ reads each file, decides where it belongs in the vault, writes a summary into the
824
+ appropriate folder, and moves the original to \`Raw/processed/\` so the inbox stays clean.
825
+
826
+ ## How to trigger a digest
827
+
828
+ Open Claude Code in this vault and type the slash command:
829
+
830
+ \`\`\`
831
+ /bp-digest
832
+ \`\`\`
833
+
834
+ One command, easy to remember. Claude does the rest: reads each file, classifies it,
835
+ files the note in the right vault folder, archives the original, and reports back.
836
+ You review the report and the inbox is empty again.
837
+
838
+ ## Examples of what to drop here
839
+
840
+ - A supplier PDF price list
841
+ - A signed customer contract or quote
842
+ - Photos of a completed job
843
+ - A transcript of a sales call (text file or audio)
844
+ - A staff training document
845
+ - Old paper records you scanned
846
+ - Spreadsheets, web pages saved as PDF, anything else
847
+
848
+ The more you drop, the more your vault knows about your shop.
849
+ `;
850
+ writeFileSync(readmePath, readme, "utf8");
851
+ return { created: true, alreadyExisted: existed };
852
+ }
853
+
854
+ function writeChatLauncher(vaultPath) {
855
+ const isWindows = process.platform === "win32";
856
+ const filename = isWindows ? "Shop OS Chat.bat" : "Shop OS Chat.command";
857
+ const filePath = join(vaultPath, filename);
858
+
859
+ let body;
860
+ if (isWindows) {
861
+ body = `@echo off
862
+ :: Shop OS Chat launcher. Double-click to start.
863
+ :: First time, Windows may show "Windows protected your PC" (SmartScreen).
864
+ :: That's expected for a new script. Click "More info" then "Run anyway".
865
+ setlocal
866
+ set "VAULT_PATH=%~dp0"
867
+ :: Strip trailing backslash
868
+ if "%VAULT_PATH:~-1%"=="\\" set "VAULT_PATH=%VAULT_PATH:~0,-1%"
869
+ echo Starting Shop OS Chat for "%VAULT_PATH%" ...
870
+ npx -y --package=github:blueprintit-ai/shop-os-chat shop-os-chat "%VAULT_PATH%"
871
+ pause
872
+ `;
873
+ } else {
874
+ body = `#!/bin/bash
875
+ # Shop OS Chat launcher. Double-click to start.
876
+ # First time, macOS may say it "cannot be opened because it is from an
877
+ # unidentified developer". That's expected. To allow it: right-click (or
878
+ # Control-click) this file, choose Open, then click Open in the dialog. You
879
+ # only need to do this once.
880
+ VAULT_PATH="$(cd "$(dirname "$0")" && pwd)"
881
+ echo "Starting Shop OS Chat for: $VAULT_PATH"
882
+ npx -y --package=github:blueprintit-ai/shop-os-chat shop-os-chat "$VAULT_PATH"
883
+ echo ""
884
+ echo "Shop OS Chat stopped. You can close this window."
885
+ read -n 1 -s -r -p ""
886
+ `;
887
+ }
888
+ writeFileSync(filePath, body, "utf8");
889
+ if (!isWindows) {
890
+ try { chmodSync(filePath, 0o755); } catch { /* ignore */ }
891
+ // Best-effort: strip the quarantine flag so the local machine doesn't show
892
+ // Gatekeeper's "unidentified developer" block on first double-click. (A copy
893
+ // synced to another Mac via Dropbox/iCloud may re-acquire it there — the
894
+ // companion help file below covers that case.)
895
+ try {
896
+ spawnSync("xattr", ["-dr", "com.apple.quarantine", filePath], { stdio: "ignore" });
897
+ } catch { /* ignore */ }
898
+ }
899
+
900
+ // Companion help note next to the launcher. Terminal output scrolls away, but
901
+ // a non-technical owner returning days later will find this right beside the
902
+ // file they're trying to open.
903
+ writeLauncherHelpNote(vaultPath, isWindows, filename);
904
+
905
+ return filePath;
906
+ }
907
+
908
+ function writeLauncherHelpNote(vaultPath, isWindows, launcherName) {
909
+ const notePath = join(vaultPath, "Open Shop OS Chat - HELP.txt");
910
+ const macSteps = `The first time you open "${launcherName}", macOS may say it
911
+ "cannot be opened because it is from an unidentified developer".
912
+ This is normal and safe. To open it:
913
+
914
+ 1. Right-click (or Control-click) "${launcherName}"
915
+ 2. Choose "Open"
916
+ 3. In the dialog that appears, click "Open" again
917
+
918
+ You only have to do this once. After that, a normal double-click works.`;
919
+ const winSteps = `The first time you open "${launcherName}", Windows may show
920
+ a blue "Windows protected your PC" screen (SmartScreen).
921
+ This is normal and safe. To open it:
922
+
923
+ 1. Click "More info"
924
+ 2. Click "Run anyway"
925
+
926
+ You only have to do this once.`;
927
+ const content = `HOW TO OPEN SHOP OS CHAT
928
+ ========================
929
+
930
+ Shop OS Chat lets your team chat with this vault (read-only).
931
+ Double-click "${launcherName}" in this folder to start it.
932
+
933
+ ${isWindows ? winSteps : macSteps}
934
+
935
+ The first launch downloads the chat app (about 20 seconds). After that it
936
+ starts quickly. To stop it, close the window.
937
+
938
+ Need help? Reply to your Shop OS welcome email.
939
+ `;
940
+ try {
941
+ writeFileSync(notePath, content, "utf8");
942
+ } catch {
943
+ // best-effort; a failed help note must not fail the install
944
+ }
945
+ }
946
+
947
+ function expandTilde(p) {
948
+ if (!p) return p;
949
+ if (p === "~") return homedir();
950
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
951
+ return p;
952
+ }
953
+
954
+ // Clean a path that came from drag-and-drop or "Copy as path" / "Copy as Pathname".
955
+ // Mac Terminal drag: backslash-escaped spaces and special chars: /Users/foo/Shop\ OS\ Vault
956
+ // Windows "Copy as path": wraps in double quotes: "C:\Users\foo\Shop OS Vault"
957
+ // Mac "Copy as Pathname": no escaping: /Users/foo/Shop OS Vault
958
+ function unwrapShellPath(p) {
959
+ if (!p) return p;
960
+ let s = p.trim();
961
+ const wasQuoted =
962
+ (s.startsWith('"') && s.endsWith('"')) ||
963
+ (s.startsWith("'") && s.endsWith("'"));
964
+ if (wasQuoted) {
965
+ s = s.slice(1, -1);
966
+ } else if (process.platform !== "win32") {
967
+ // Mac Terminal drag uses backslash to escape spaces and special chars.
968
+ // On Windows, backslashes are path separators — leave them alone.
969
+ s = s.replace(/\\(.)/g, "$1");
970
+ }
971
+ return s.trim();
972
+ }
973
+
974
+ function detectSyncFolders() {
975
+ const home = homedir();
976
+ return [
977
+ { name: "Dropbox", path: join(home, "Dropbox") },
978
+ { name: "iCloud Drive", path: join(home, "Library/Mobile Documents/com~apple~CloudDocs") },
979
+ { name: "OneDrive", path: join(home, "OneDrive") },
980
+ ].filter((f) => existsSync(f.path));
981
+ }
982
+
983
+ function printVaultLocationGuide() {
984
+ print(bold("Step 1 of 2: create your vault folder"));
985
+ print("");
986
+ print(" Open Finder (Mac) or File Explorer (Windows).");
987
+ print(" Right-click in the location where you want your vault, choose");
988
+ print(" " + bold("New Folder") + ", and name it " + cyan("Shop OS Vault") + ".");
989
+ print("");
990
+ print(" " + bold("Where to put it:"));
991
+ print(" " + cyan("Single computer") + " -> your home folder or Desktop");
992
+ print(" " + cyan("Multiple machines") + " -> inside Dropbox, iCloud Drive,");
993
+ print(" or OneDrive (any computer signed in to");
994
+ print(" the same account will see the same vault)");
995
+ print("");
996
+ print(" " + dim("Disk space: under 50 MB on day one, 2-5 GB after a year of"));
997
+ print(" " + dim("heavy use. Make sure the drive has 10 GB free."));
998
+ print("");
999
+ print(bold("Step 2 of 2: tell the installer where it is"));
1000
+ print("");
1001
+ print(" When the prompt below appears, " + bold("drag the folder you just created"));
1002
+ print(" " + bold("from Finder / File Explorer into this terminal window") + ". The full");
1003
+ print(" path appears automatically. Press Enter.");
1004
+ print("");
1005
+ print(" " + dim("If drag-and-drop does not work:"));
1006
+ print(" " + dim("Mac: right-click the folder, hold Option, choose"));
1007
+ print(" " + dim(' "Copy as Pathname", then paste here with Cmd+V'));
1008
+ print(" " + dim("Windows: Shift + right-click the folder, choose"));
1009
+ print(" " + dim(' "Copy as path", then paste here with Ctrl+V'));
1010
+ print("");
1011
+ }
1012
+
1013
+ function saveLicenseFile(license) {
1014
+ const dir = join(homedir(), ".shopos");
1015
+ mkdirSync(dir, { recursive: true });
1016
+ const path = join(dir, "license.json");
1017
+ const record = {
1018
+ key: license.key || null,
1019
+ customer: license.customer,
1020
+ product: license.product,
1021
+ entitlements: license.entitlements,
1022
+ valid_until: license.valid_until,
1023
+ activated_at: new Date().toISOString(),
1024
+ server: LICENSE_SERVER,
1025
+ };
1026
+ writeFileSync(path, JSON.stringify(record, null, 2) + "\n", "utf8");
1027
+ try {
1028
+ chmodSync(path, 0o600);
1029
+ } catch {
1030
+ // Windows: chmod is a no-op, ignore
1031
+ }
1032
+ return path;
1033
+ }
1034
+
1035
+ // ---------- arg parsing ----------
1036
+
1037
+ function parseArgs(argv) {
1038
+ const args = { license: null, vault: null, yes: false, existing: false, help: false };
1039
+ for (let i = 0; i < argv.length; i++) {
1040
+ const a = argv[i];
1041
+ if (a === "--help" || a === "-h") args.help = true;
1042
+ else if (a === "--yes" || a === "-y") args.yes = true;
1043
+ else if (a === "--existing" || a === "-e") args.existing = true;
1044
+ else if (a === "--license") args.license = argv[++i];
1045
+ else if (a.startsWith("--license=")) args.license = a.slice("--license=".length);
1046
+ else if (a === "--vault") args.vault = argv[++i];
1047
+ else if (a.startsWith("--vault=")) args.vault = a.slice("--vault=".length);
1048
+ }
1049
+ return args;
1050
+ }
1051
+
1052
+ function printHelp() {
1053
+ print("");
1054
+ print(bold("Usage:") + " npx @blueprintitai/shop-os-install [options]");
1055
+ print("");
1056
+ print(bold("Options:"));
1057
+ print(` --license <KEY> License key (skips interactive prompt)`);
1058
+ print(` --vault <PATH> Vault location (skips interactive prompt)`);
1059
+ print(` --existing, -e Add Shop OS to an existing vault (skips vault creation)`);
1060
+ print(` --yes, -y Skip the install-here confirmation`);
1061
+ print(` --help, -h Show this message`);
1062
+ print("");
1063
+ }
1064
+
1065
+ // ---------- main flow ----------
1066
+
1067
+ async function main() {
1068
+ const args = parseArgs(process.argv.slice(2));
1069
+ if (args.help) {
1070
+ printHelp();
1071
+ exit(0);
1072
+ }
1073
+
1074
+ banner();
1075
+
1076
+ print(bold("Pre-flight checks"));
1077
+ const nodeVersion = checkNode();
1078
+ ok(`Node ${nodeVersion}`);
1079
+ const claudeRoot = checkClaudeCode();
1080
+ ok(`Claude Code detected at ${claudeRoot}`);
1081
+ await checkConnectivity();
1082
+ print("");
1083
+
1084
+ const rl = createInterface({ input: stdin, output: stdout });
1085
+
1086
+ let license;
1087
+ currentStep = "license_validation";
1088
+ // License entry: 1 attempt if --license flag given, else up to 3 interactive attempts.
1089
+ const maxAttempts = args.license ? 1 : 3;
1090
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1091
+ const rawKey = args.license || (await ask(rl, "License key"));
1092
+ if (!rawKey) fail("No license key provided.");
1093
+ const key = normalizeLicenseKey(rawKey);
1094
+ currentLicenseKey = key;
1095
+
1096
+ // Instant feedback on an obviously-malformed key, saving a server round-trip
1097
+ // on a typo. Interactive only — an automated --license run still defers to
1098
+ // the server so a future format change can't break it.
1099
+ if (!args.license && !looksLikeLicenseKey(key)) {
1100
+ warn(`That doesn't look like a Shop OS key. The format is ${bold("SHOP-XXXX-XXXX-XXXX")}.`);
1101
+ if (attempt < maxAttempts) {
1102
+ print(" " + dim("Check your welcome email for the exact key, then try again."));
1103
+ continue;
1104
+ }
1105
+ print("");
1106
+ fail("No valid license key entered. Reply to your welcome email for help: " + SUPPORT_URL);
1107
+ }
1108
+
1109
+ print(" " + dim("Validating against license server..."));
1110
+ const result = await validateLicense(key);
1111
+ if (result.ok) {
1112
+ license = { ...result.license, key };
1113
+ ok(`License valid for ${bold(license.customer)}`);
1114
+ info(`Product: ${license.product}`);
1115
+ info(`Entitlements: ${license.entitlements.join(", ")}`);
1116
+ break;
1117
+ }
1118
+ warn(`License rejected: ${result.error}`);
1119
+ if (attempt < maxAttempts) {
1120
+ print(" " + dim("Try again, or press Ctrl-C to cancel."));
1121
+ } else {
1122
+ print("");
1123
+ fail("License validation failed. Reply to your welcome email for help: " + SUPPORT_URL);
1124
+ }
1125
+ }
1126
+ print("");
1127
+
1128
+ // Vault mode: new or existing?
1129
+ let isExisting = args.existing;
1130
+ if (!isExisting && !args.vault) {
1131
+ print(bold("Vault mode"));
1132
+ print("");
1133
+ print(" " + bold("new") + " Create a fresh Shop OS vault in a new folder");
1134
+ print(" " + bold("existing") + " Add Shop OS to a vault you already have");
1135
+ print("");
1136
+ const modeAns = await ask(rl, "New vault or add to existing?", { default: "new" });
1137
+ isExisting = modeAns.toLowerCase().startsWith("e");
1138
+ print("");
1139
+ }
1140
+
1141
+ // Vault location (flag overrides prompt)
1142
+ let vaultPath = args.vault;
1143
+ if (!vaultPath) {
1144
+ if (isExisting) {
1145
+ for (let attempt = 1; attempt <= 3; attempt++) {
1146
+ const ans = await ask(rl, "Drag your existing vault folder here, then press Enter");
1147
+ if (ans) { vaultPath = ans; break; }
1148
+ warn("No path entered. Drag the folder into this window, or paste the copied path.");
1149
+ }
1150
+ } else {
1151
+ printVaultLocationGuide();
1152
+ for (let attempt = 1; attempt <= 3; attempt++) {
1153
+ const ans = await ask(rl, "Drag your Shop OS Vault folder here, then press Enter");
1154
+ if (ans) { vaultPath = ans; break; }
1155
+ warn("No path entered. Drag the folder from Finder / File Explorer into this window, or paste the copied path.");
1156
+ }
1157
+ }
1158
+ if (!vaultPath) {
1159
+ rl.close();
1160
+ fail("No vault path provided. Create the folder first, then re-run this installer.");
1161
+ }
1162
+ }
1163
+ vaultPath = resolve(expandTilde(unwrapShellPath(vaultPath)));
1164
+
1165
+ if (!existsSync(vaultPath)) {
1166
+ if (isExisting) {
1167
+ rl.close();
1168
+ fail(`No folder found at: ${vaultPath}\nMake sure the path is correct and the folder exists.`);
1169
+ }
1170
+ // Vault folder doesn't exist yet — that's expected for a new install.
1171
+ // In --yes mode (setup scripts), proceed silently; the "[3/N] Creating
1172
+ // vault at ..." step below prints its own status. In interactive mode,
1173
+ // confirm with the user before creating.
1174
+ if (!args.yes) {
1175
+ const createIt = await confirm(rl, `Create new vault at ${cyan(vaultPath)}?`, { default: true });
1176
+ if (!createIt) {
1177
+ rl.close();
1178
+ fail("Create the folder in Finder / File Explorer first, then re-run this installer.");
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ const confirmMsg = isExisting
1184
+ ? `Add Shop OS to existing vault at ${cyan(vaultPath)}?`
1185
+ : `Install Shop OS into ${cyan(vaultPath)}?`;
1186
+ const proceed = args.yes
1187
+ ? true
1188
+ : await confirm(rl, confirmMsg, { default: true });
1189
+
1190
+ if (!proceed) {
1191
+ rl.close();
1192
+ print("");
1193
+ print(yellow("Cancelled. No changes made."));
1194
+ exit(0);
1195
+ }
1196
+ rl.close();
1197
+ print("");
1198
+
1199
+ // Step-by-step install
1200
+ print(bold("Installing Shop OS"));
1201
+
1202
+ // The vault directory has to exist before the plugin step: `claude plugin
1203
+ // install --scope project` runs inside it. Step [3/7] reports on it below.
1204
+ const vaultExistedBefore = existsSync(vaultPath);
1205
+ mkdirSync(vaultPath, { recursive: true });
1206
+
1207
+ currentStep = "marketplaces";
1208
+ print(dim(" [1/7] Registering plugin marketplaces"));
1209
+ const mpResult = await ensureMarketplaces(claudeRoot);
1210
+ if (mpResult.added.length === 0) {
1211
+ info(`All ${mpResult.total} marketplaces already registered`);
1212
+ } else {
1213
+ for (const name of mpResult.added) ok(`Added marketplace: ${name}`);
1214
+ }
1215
+
1216
+ // Smoke test the downloaded marketplaces before scaffolding the vault, so a bad
1217
+ // download fails fast (nothing to clean up) instead of mid-way through.
1218
+ currentStep = "verify_plugins";
1219
+ const problems = verifyInstall(claudeRoot);
1220
+ if (problems.length === 0) {
1221
+ ok("Verified required plugins are present in their marketplaces");
1222
+ } else {
1223
+ for (const p of problems) warn(`${p.id}: ${p.reason}`);
1224
+ if (problems.some((p) => p.fatal)) {
1225
+ fail(
1226
+ "Required Shop OS skills are missing from the downloaded marketplaces.\n\n" +
1227
+ " The download landed but does not contain the plugin behind /bp-setup,\n" +
1228
+ " usually a partial or interrupted clone. Re-run the installer — nothing\n" +
1229
+ " was finalized, so re-running is safe.",
1230
+ );
1231
+ }
1232
+ info("Marketplaces downloaded but couldn't be fully verified. If /bp-setup is missing on first launch, re-run this installer.");
1233
+ }
1234
+
1235
+ currentStep = "plugins";
1236
+ print(dim(" [2/7] Installing plugins from marketplaces"));
1237
+ const pluginsResult = ensurePluginsInstalled(claudeRoot, vaultPath);
1238
+ for (const id of PLUGINS_TO_ENABLE) {
1239
+ if (pluginsResult.pending.includes(id)) continue;
1240
+ const via = pluginsResult.installedViaCli.includes(id) ? " (via Claude Code)" : "";
1241
+ ok(`Installed plugin: ${id}${via}`);
1242
+ }
1243
+ // verifyInstall() only inspects the marketplace manifest, so a plugin that
1244
+ // neither copied nor installed through the CLI has to be called out here.
1245
+ if (pluginsResult.pending.length > 0) {
1246
+ for (const id of pluginsResult.pending) {
1247
+ warn(`Could not install ${id}.`);
1248
+ }
1249
+ info(`After signing in to Claude Code, run: claude plugin install ${pluginsResult.pending[0]} --scope project`);
1250
+ info("(inside the vault folder), or re-run this installer.");
1251
+ }
1252
+
1253
+ currentStep = "vault_create";
1254
+ print(dim(` [3/7] ${isExisting ? "Configuring" : "Creating"} vault at ${vaultPath}`));
1255
+ if (!vaultExistedBefore) {
1256
+ ok("Vault directory created");
1257
+ } else {
1258
+ info(`Vault directory ${isExisting ? "found" : "already exists"}`);
1259
+ }
1260
+ if (!isExisting) {
1261
+ const wroteClaudeMd = createVaultClaudeMd(vaultPath, license);
1262
+ if (wroteClaudeMd) ok("CLAUDE.md scaffolded");
1263
+ else info("CLAUDE.md already present (left untouched)");
1264
+ }
1265
+
1266
+ const rawResult = createRawInbox(vaultPath);
1267
+ if (rawResult.created) ok("Raw/ inbox + Raw/processed/ created (drop materials in Raw/ to seed the vault)");
1268
+ else info("Raw/ inbox already present (left untouched)");
1269
+
1270
+ currentStep = "vault_settings";
1271
+ print(dim(" [4/7] Enabling plugins + permission allowlist for this vault"));
1272
+ const settingsPath = enableForVault(vaultPath);
1273
+ ok(`Wrote ${settingsPath.replace(homedir(), "~")}`);
1274
+ info("Pre-approved Read/Write/Edit/Bash patterns so /bp-setup runs without permission prompts");
1275
+ const userSettingsPath = enableForUser(claudeRoot);
1276
+ ok(`Enabled Shop OS plugins machine-wide in ${userSettingsPath.replace(homedir(), "~")}`);
1277
+ info("/bp commands work in any folder and in IDE extensions, not just the vault");
1278
+
1279
+ currentStep = "seed_defaults";
1280
+ print(dim(" [5/7] Pre-seeding Claude Code defaults"));
1281
+ const seeded = seedClaudeCodeDefaults(claudeRoot);
1282
+ if (seeded.onboardingFlags || seeded.theme) {
1283
+ if (seeded.onboardingFlags) ok("Marked Claude Code onboarding complete (skips theme/terminal wizard on first launch)");
1284
+ if (seeded.theme) ok("Set Claude Code theme to dark");
1285
+ } else {
1286
+ info("Claude Code already configured — left existing settings untouched");
1287
+ }
1288
+
1289
+ currentStep = "save_license";
1290
+ print(dim(" [6/7] Saving license"));
1291
+ const licensePath = saveLicenseFile(license);
1292
+ ok(`License saved to ${licensePath.replace(homedir(), "~")} (chmod 600)`);
1293
+
1294
+ currentStep = "launcher";
1295
+ print(dim(" [7/7] Installing Shop OS Chat launcher"));
1296
+ const launcherPath = writeChatLauncher(vaultPath);
1297
+ ok(`Wrote ${launcherPath.replace(homedir(), "~")}`);
1298
+
1299
+ currentStep = "complete";
1300
+
1301
+ print("");
1302
+ print(green(bold("✓ Shop OS installation complete!")));
1303
+ print("");
1304
+ print(bold("Next steps:"));
1305
+ print(` 1. Open the ${cyan("Claude Code")} app you installed (Applications / Start menu)`);
1306
+ print(` 2. Pick this folder when it asks which to open:`);
1307
+ print(` ${cyan(vaultPath)}`);
1308
+ print(` 3. Type ${cyan("/bp-setup")} to personalize your vault`);
1309
+ print(` 4. Walk through the onboarding interview`);
1310
+ print("");
1311
+ print(` 5. To let your team chat with the vault (read-only),`);
1312
+ print(` double-click ${cyan("Shop OS Chat.command")} (Mac) or ${cyan("Shop OS Chat.bat")} (Windows)`);
1313
+ print(` in your vault folder. First launch downloads the chat (~20 seconds).`);
1314
+ print(` ${dim("On first open your OS may show a security prompt (Mac: right-click >")}`);
1315
+ print(` ${dim('Open; Windows: More info > Run anyway). See "Open Shop OS Chat - HELP.txt".')}`);
1316
+ print("");
1317
+ print(dim(`Support: ${SUPPORT_URL}`));
1318
+ print("");
1319
+ }
1320
+
1321
+ main()
1322
+ .then(async () => {
1323
+ // currentStep is "complete" once main() returns successfully.
1324
+ await sendInstallLog("success");
1325
+ })
1326
+ .catch(async (err) => {
1327
+ await sendInstallLog("error", err.message);
1328
+ print("");
1329
+ // InstallError messages are already worded for the customer; anything else is
1330
+ // an unexpected crash, so label it as such.
1331
+ const msg = err && err.handled ? err.message : `Unexpected error: ${err && err.message}`;
1332
+ stderr.write(red("✗ ") + msg + "\n");
1333
+ exit(1);
1334
+ });