@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.
- package/README.md +156 -0
- package/bin/shop-os-install.js +1334 -0
- package/bin/shop-os-update.js +243 -0
- package/package.json +32 -0
|
@@ -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
|
+
});
|