From aa65f3d11a557588a0d7164969200d13a3334973 Mon Sep 17 00:00:00 2001 From: Brandt Milczewski Date: Tue, 28 Oct 2025 15:33:06 -0700 Subject: [PATCH 1/3] Add campaign tools and tests --- ROADMAP.md | 816 ++++++++++++++++++++++ src/__tests__/unit/campaign-tools.test.ts | 370 ++++++++++ src/__tests__/unit/cli.test.ts | 1 + src/lib/core/campaign-tools.ts | 298 ++++++++ src/lib/core/cli.ts | 7 +- 5 files changed, 1491 insertions(+), 1 deletion(-) create mode 100644 ROADMAP.md create mode 100644 src/__tests__/unit/campaign-tools.test.ts create mode 100644 src/lib/core/campaign-tools.ts diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f72466a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,816 @@ +# Product Roadmap - Multi-Shop CLI + +**Last Updated:** 2025-10-28 +**Current Version:** v2.2.4 + +--- + +## Vision + +Eliminate the complexity and risk of managing multiple Shopify stores from a single theme codebase, enabling teams to ship faster with confidence. + +**Aligned with Shopify's official recommendations:** +> "For events like sales, consider using non-main branches to customize your theme. Themes connected to these branches can be published temporarily." +> — [Shopify Theme Version Control Best Practices](https://shopify.dev/docs/storefronts/themes/best-practices/version-control) + +Multi-shop CLI automates these workflows that Shopify recommends but doesn't provide tooling for. + +--- + +## Problem Space Analysis + +### The Core Challenge + +Managing multiple Shopify stores from one theme codebase involves several competing concerns: + +**1. Code Sharing** (✅ Working well) +- Bug fixes should propagate to all shops +- New features should be available everywhere +- Improvements benefit the entire fleet + +**2. Content Isolation** (⚠️ Partially solved) +- Each shop has unique branding (colors, fonts, text) +- Settings should NOT sync across shops +- Accidental overwrites cause real damage +- Currently: Detection + warnings (not prevention) + +**3. Campaign Management** (❌ Manual/undocumented) +- Time-sensitive promotions need isolated branches +- Promo content should eventually merge to shop main +- Cleanup after campaigns is manual and error-prone +- No tooling support currently + +**4. Team Coordination** (⚠️ Minimal support) +- Multiple teams work on different shops +- Shop ownership unclear in tooling +- No notification system for cross-shop changes +- Manual coordination required + +**5. Testing Complexity** (⚠️ Basic support) +- Need to test changes across multiple shop contexts +- Visual regression testing not integrated +- No automated cross-shop testing +- Preview link management is manual + +--- + +## User Personas & Pain Points + +### Persona 1: Solo Developer (2-5 shops) + +**Current Pain Points:** +- 😤 Manually creating promo branches +- 😤 Remembering which shop has which customizations +- 😤 Testing same feature across all shops (repetitive) +- 😤 Cleaning up after campaigns + +**What they need:** +- One-click promo branch creation +- Quick shop context switching +- Automated cleanup workflows +- Simple campaign lifecycle management + +### Persona 2: Small Team (5-15 shops) + +**Current Pain Points:** +- 😤 Coordinating who works on which shop +- 😤 Accidental content overwrites (detection helps but not prevents) +- 😤 No visibility into shop sync status +- 😤 Manual testing across all shops + +**What they need:** +- Shop ownership tracking +- Content protection (not just warnings) +- Sync status dashboard +- Automated cross-shop testing + +### Persona 3: Enterprise (15+ shops) + +**Current Pain Points:** +- 😤 Shop sprawl (too many shops to manage) +- 😤 No audit trail for changes +- 😤 Complex approval workflows +- 😤 Deployment coordination across regions/teams + +**What they need:** +- Shop grouping and organization +- Comprehensive audit logging +- Advanced approval workflows +- Deployment scheduling and coordination + +--- + +## Feature Roadmap + +### Immediate Priority (v2.3.0) - Next 2-4 weeks + +**Theme: Campaign Management Automation** + +#### Feature 1: Campaign Tools Menu ⭐⭐⭐⭐⭐ + +**Problem:** Promo/campaign workflows are entirely manual +**Impact:** High - Core use case, frequently needed +**Effort:** Medium (~8 hours) +**Shopify Validation:** ✅ Shopify explicitly recommends branch-per-campaign: "Connect one or more branches from a repository to easily develop and test new theme features or campaigns" ([Source](https://shopify.dev/docs/storefronts/themes/tools/github)) + +**Implementation:** +```typescript +// Add to cli.ts main menu: +{ value: "campaign", label: "Campaign Tools", hint: "Manage promos and campaigns" } + +// Campaign submenu: +- Create Promo Branch (shop-a/main → shop-a/promo-NAME) +- Push Promo to Main (shop-a/promo-NAME → shop-a/main PR) +- End Promo (cleanup branch, optional content revert) +- List Active Promos (show all promo branches) +``` + +**User Story:** +``` +As a marketer managing Shop A, +I want to create a promo branch with one command, +So I can quickly set up seasonal campaigns without manual git commands. +``` + +**Acceptance Criteria:** +- [ ] "Campaign Tools" appears in main menu +- [ ] Can create promo branch from shop/main +- [ ] Validates shop exists before creating promo +- [ ] Auto-pushes branch to GitHub +- [ ] Creates helpful commit message with promo name +- [ ] Shows next steps (connect to Shopify theme) + +**Files to create:** +- `src/lib/core/campaign-tools.ts` (~150 lines) +- `src/lib/core/campaign-operations.ts` (~120 lines) +- `src/__tests__/unit/campaign-tools.test.ts` (~50 tests) + +--- + +#### Feature 2: Content File Protection (Not Just Detection) ⭐⭐⭐⭐⭐ + +**Problem:** Current detection warns but doesn't prevent overwrites +**Impact:** Critical - Prevents data loss +**Effort:** Low (~3 hours) + +**Implementation:** +```typescript +// Auto-create .gitattributes in shop branches +const setupContentProtection = (shopId: string) => { + const gitattributes = ` +# Shop-specific content files (always prefer shop version during merges) +config/settings_data.json merge=ours +templates/*.json merge=ours +locales/*.json merge=ours +config/markets.json merge=ours + `.trim(); + + writeFile(`${shopId}/main`, '.gitattributes', gitattributes); +}; +``` + +**User Story:** +``` +As a shop manager, +I want content files to automatically use shop version during merges, +So I never accidentally lose my shop's customizations. +``` + +**Acceptance Criteria:** +- [ ] Auto-creates .gitattributes on shop branch creation +- [ ] Documents merge=ours strategy in created file +- [ ] Updates existing shops with `pnpm run shop → Tools → Setup Content Protection` +- [ ] Warns if .gitattributes is missing +- [ ] Tests verify merge behavior + +--- + +#### Feature 3: Shop Health Check ⭐⭐⭐⭐ + +**Problem:** No way to verify shop configuration is correct +**Impact:** Medium-High - Prevents issues +**Effort:** Low (~4 hours) + +**Implementation:** +```typescript +// New menu option: Tools → Health Check +const healthCheck = async (shopId: string) => { + const results = { + configValid: await validateConfig(shopId), + credentialsExist: await checkCredentials(shopId), + branchesExist: await checkBranches(shopId), + githubConnected: await checkGitHubIntegration(shopId), + contentProtected: await checkGitAttributes(shopId) + }; + + displayHealthReport(results); +}; +``` + +**Output:** +``` +🏥 Shop Health Check: shop-a + +✅ Configuration valid +✅ Credentials exist (production, staging) +✅ Branches exist (shop-a/main, shop-a/staging) +⚠️ GitHub integration not verified (check manually) +❌ Content protection not configured (.gitattributes missing) + +Recommendations: + 1. Run: Tools → Setup Content Protection + 2. Verify GitHub connection in Shopify Admin +``` + +--- + +### Near-Term (v2.4.0) - 1-2 months + +**Theme: Developer Experience & Testing** + +#### Feature 4: Quick Shop Switcher in Dev Mode ⭐⭐⭐⭐ + +**Problem:** Must exit and restart dev server to switch shops +**Impact:** Medium - DX improvement, saves time +**Effort:** Medium (~6 hours) + +**Implementation:** +```typescript +// During dev server, watch for keypress +process.stdin.on('data', async (key) => { + if (key.toString() === 's') { // 's' for switch + console.log('\n🔄 Switch Shop'); + const newShop = await selectShop(); + restartDevServer(newShop); + } +}); +``` + +**User Story:** +``` +As a developer testing a feature, +I want to switch between shops without exiting dev mode, +So I can quickly test across multiple shop contexts. +``` + +--- + +#### Feature 5: Shop Cloning ⭐⭐⭐⭐ + +**Problem:** Creating similar shops requires manual duplication +**Impact:** Medium - Saves time for new shop setup +**Effort:** Low (~3 hours) + +**Implementation:** +```bash +pnpm run shop → Create New Shop → Clone from existing shop + +# Copies config from shop-a, prompts for new values: +Source shop: shop-a +New shop ID: shop-b +New name: Shop B +New production domain: shop-b.myshopify.com +... +``` + +**Copies:** +- ✅ Authentication method +- ✅ Branch naming pattern +- ❌ NOT credentials (security) +- ❌ NOT content (shop-specific) + +--- + +#### Feature 6: Preview Link Manager ⭐⭐⭐ + +**Problem:** Managing Shopify preview links across shops is manual +**Impact:** Medium - QA and sharing +**Effort:** Low (~3 hours) + +**Implementation:** +```bash +pnpm run shop → Tools → Generate Preview Links + +Generating preview links for all shops... + +shop-a (staging): + Preview: https://shop-a.myshopify.com/?preview_theme_id=123456 + Editor: https://shop-a.myshopify.com/admin/themes/123456/editor + +shop-b (staging): + Preview: https://shop-b.myshopify.com/?preview_theme_id=789012 + ... + +[Copy all] [Copy shop-a only] [Open in browser] +``` + +--- + +### Mid-Term (v2.5.0) - 2-3 months + +**Theme: Content Management & Collaboration** + +#### Feature 7: Content Snapshot & Restore ⭐⭐⭐⭐⭐ + +**Problem:** No way to backup/restore shop content before risky operations +**Impact:** High - Safety net for content changes +**Effort:** Medium (~8 hours) + +**Implementation:** +```bash +pnpm run shop → Tools → Content Management + +Options: + → Create Content Snapshot (shop-a) + → Restore Content Snapshot + → Compare Content (shop-a vs shop-b) + → Pull Content from Shopify +``` + +**Use Cases:** +1. **Before major sync:** Snapshot content, try sync, rollback if needed +2. **Clone shop content:** Copy shop-a settings to new shop-b +3. **Debug differences:** Compare why shop-a looks different than shop-b + +**Implementation:** +```typescript +const createSnapshot = async (shopId: string) => { + // Pull current content from Shopify + execSync(`shopify theme pull --only=config/settings_data.json --only=templates/*.json`); + + // Save with timestamp + const snapshot = { + shopId, + timestamp: new Date().toISOString(), + files: { + 'config/settings_data.json': readFile(...), + 'templates/index.json': readFile(...), + ... + } + }; + + writeFile(`shops/snapshots/${shopId}-${timestamp}.json`, snapshot); +}; +``` + +--- + +#### Feature 8: Shop Ownership & CODEOWNERS Automation ⭐⭐⭐⭐ + +**Problem:** No tracking of which team owns which shop +**Impact:** High for teams - Clear ownership +**Effort:** Low (~4 hours) + +**Implementation:** +```bash +pnpm run shop → Edit Shop → Set Owner/Team + +Shop: shop-a +Current owner: None +New owner: @marketing-team + +# Auto-updates CODEOWNERS: +shops/shop-a.config.json @marketing-team +shops/shop-a/* @marketing-team +``` + +**Benefits:** +- Auto-requests review from shop team +- Clear responsibility +- GitHub notifications to right people + +--- + +#### Feature 9: Bulk Shop Operations ⭐⭐⭐ + +**Problem:** Performing same operation across all shops is tedious +**Impact:** Medium - Efficiency for large fleets +**Effort:** Low (~3 hours) + +**Implementation:** +```bash +pnpm run shop → Tools → Bulk Operations + +Select operation: + → Update all shop configs (batch edit) + → Sync all shops at once + → Health check all shops + → Generate all preview links + → Archive inactive shops + +Select shops: + [x] shop-a + [x] shop-b + [ ] shop-c (inactive) + [x] Select all active +``` + +--- + +### Long-Term (v3.0.0) - 3-6 months + +**Theme: Advanced Workflows & Enterprise Features** + +#### Feature 10: Visual Regression Testing Integration ⭐⭐⭐⭐⭐ + +**Problem:** No way to catch visual changes when syncing code +**Impact:** Very High - Prevents broken UIs in production +**Effort:** High (~16 hours) + +**Integration with:** +- Percy.io +- Chromatic +- Or custom screenshot comparison + +**Implementation:** +```bash +pnpm run shop → Tools → Visual Testing + +# Takes screenshots of key pages across all shops +# Compares before/after for PRs +# Flags visual differences for review +``` + +**User Story:** +``` +As a developer syncing CSS changes, +I want to see visual diffs across all shops, +So I can catch unintended visual regressions before deployment. +``` + +--- + +#### Feature 11: Deployment Scheduling & Coordination ⭐⭐⭐⭐ + +**Problem:** No coordination for deploying to multiple shops +**Impact:** High for enterprises - Reduces deployment risk +**Effort:** High (~12 hours) + +**Implementation:** +```bash +pnpm run shop → Tools → Schedule Deployment + +Deploy: main → all shop staging branches +When: Tonight at 2 AM EST (low traffic) +Shops: shop-a, shop-b, shop-c (3 selected) +Rollback: Automatic if theme check fails + +[Schedule] [Deploy now] [Cancel] +``` + +**Features:** +- Schedule deployments for low-traffic hours +- Staggered rollout (shop-a first, wait 1 hour, then shop-b) +- Automatic rollback if errors detected +- Notification when complete +- Deployment status dashboard + +--- + +#### Feature 12: Shop Template System ⭐⭐⭐⭐ + +**Problem:** Setting up similar shops requires repetitive configuration +**Impact:** Medium-High - Accelerates shop creation +**Effort:** Medium (~6 hours) + +**Implementation:** +```bash +# Save shop as template +pnpm run shop → Tools → Save as Template +Shop: shop-a +Template name: beauty-store-template +Description: Standard beauty store setup with reviews + +# Create from template +pnpm run shop → Create New Shop → From Template +Template: beauty-store-template +New shop ID: shop-f +# Pre-fills auth method, branch pattern, structure +``` + +**Template includes:** +- Authentication method preference +- Branch naming convention +- Recommended GitHub workflow +- Does NOT include: credentials, content, domains + +--- + +#### Feature 13: Sync Status Dashboard ⭐⭐⭐ + +**Problem:** No visibility into which shops are in sync with main +**Impact:** Medium - Operational visibility +**Effort:** Medium (~8 hours) + +**Implementation:** +```bash +pnpm run shop → Tools → Sync Status + +Sync Status Dashboard: + +shop-a/staging: ✅ Up to date (0 commits behind main) +shop-a/main: ⚠️ 2 commits behind staging +shop-b/staging: ❌ 15 commits behind main (last sync: 7 days ago) +shop-b/main: ✅ Up to date +shop-c/staging: ✅ Up to date (0 commits behind main) + +Recommendations: + - Shop B needs sync (15 commits behind) + - Shop A: Deploy staging to main + +[Sync all outdated] [View details] +``` + +--- + +### Future Exploration (v3.1.0+) - 6+ months + +**Theme: Advanced Features & Integrations** + +#### Feature 14: Content Diffing & Smart Merge ⭐⭐⭐⭐⭐ + +**Problem:** No way to see content differences between shops +**Impact:** Very High - Understanding shop variations +**Effort:** Very High (~20 hours) + +**Implementation:** +```bash +pnpm run shop → Tools → Content Diff + +Compare: shop-a vs shop-b + +config/settings_data.json: + Colors: + shop-a: #FF0000 (red) + shop-b: #0000FF (blue) ← Different + + Fonts: + shop-a: "Helvetica" + shop-b: "Helvetica" ← Same + + Logo: + shop-a: logo-fitness.png + shop-b: logo-beauty.png ← Different + +[Export diff] [Copy shop-a to shop-b] [Ignore differences] +``` + +**Smart Merge:** +- Identify which content differences are intentional +- Suggest which to sync, which to keep different +- One-click selective content sync + +--- + +#### Feature 15: Multi-Shop Dev Server ⭐⭐⭐ + +**Problem:** Can only preview one shop at a time +**Impact:** Medium - Testing efficiency +**Effort:** High (~12 hours) + +**Implementation:** +```bash +pnpm run dev --multi + +Starting dev servers for all shops: + shop-a: http://localhost:9292 + shop-b: http://localhost:9293 + shop-c: http://localhost:9294 + +[s] Switch focus [r] Reload all [q] Quit + +Focus: shop-a +``` + +**Challenges:** +- Multiple Shopify CLI instances +- Port management +- Resource usage +- Terminal UI complexity + +--- + +#### Feature 16: Deployment Pipeline Integration ⭐⭐⭐⭐ + +**Problem:** No integration with deployment tools +**Impact:** High for enterprises +**Effort:** Very High (~16 hours) + +**Integrations:** +- Slack notifications on sync/deploy +- Datadog deployment tracking +- PagerDuty integration for failures +- Custom webhooks for deployment events + +--- + +#### Feature 17: Shop Archiving & Lifecycle ⭐⭐⭐ + +**Problem:** No way to mark shops as inactive/archived +**Impact:** Medium - Organization +**Effort:** Low (~4 hours) + +**Implementation:** +```bash +pnpm run shop → Archive Shop + +Shop: shop-old +Archive reason: Store closed + +Actions: + ✅ Move config to shops/archived/ + ✅ Keep credentials (for reference) + ✅ Add "archived: true" to config + ✅ Exclude from sync operations + ✅ Preserve git branches (read-only) + +[Archive] [Cancel] +``` + +--- + +#### Feature 18: Interactive Onboarding Tour ⭐⭐⭐ + +**Problem:** New users don't know where to start +**Impact:** Medium - UX improvement +**Effort:** Medium (~6 hours) + +**Implementation:** +```bash +# First time running: +npx multi-shop + +🎉 Welcome to Multi-Shop! + +This looks like your first time. Want a quick tour? +[Yes, show me around] [No, I'll explore myself] + +→ Step 1/5: Creating your first shop +→ Step 2/5: Setting up credentials +→ Step 3/5: Running development server +→ Step 4/5: Creating a feature branch +→ Step 5/5: Syncing shops + +[Skip] [Previous] [Next] +``` + +--- + +## Prioritization Framework + +### Impact vs Effort Matrix + +``` +HIGH IMPACT, LOW EFFORT (Do First): +├─ Shop Health Check (v2.3.0) +├─ Content Protection (.gitattributes automation) (v2.3.0) +├─ Shop Cloning (v2.4.0) +└─ Shop Archiving (v2.4.0) + +HIGH IMPACT, MEDIUM EFFORT (Core Features): +├─ Campaign Tools Menu (v2.3.0) ⭐ Priority +├─ Shop Ownership/CODEOWNERS (v2.4.0) +├─ Preview Link Manager (v2.4.0) +└─ Sync Status Dashboard (v2.4.0) + +HIGH IMPACT, HIGH EFFORT (Major Features): +├─ Content Snapshot & Restore (v2.5.0) +├─ Visual Regression Testing (v3.0.0) +├─ Content Diffing & Smart Merge (v3.0.0) +└─ Deployment Pipeline Integration (v3.0.0) + +MEDIUM IMPACT (Nice to Have): +├─ Quick Shop Switcher (v2.4.0) +├─ Bulk Shop Operations (v2.4.0) +├─ Interactive Onboarding (v2.5.0) +└─ Multi-Shop Dev Server (v3.0.0) +``` + +--- + +## Recommended v2.3.0 Scope (Next Release) + +**Focus:** Campaign Management + Content Protection + +**Features to include:** +1. ✅ Campaign Tools Menu (highest user demand) +2. ✅ Content Protection Automation (.gitattributes) +3. ✅ Shop Health Check (quick win) + +**Why this combination:** +- Addresses biggest pain point (manual promo workflows) +- Strengthens content protection (moves from detection → prevention) +- Adds operational confidence (health checks) +- All deliverable in 2-3 weeks +- Clear value proposition for v2.3.0 release + +**Estimated effort:** ~15 hours total + +--- + +## Community-Requested Features (Track as they come in) + +**Once open source, users will request features. Track here:** + +- [ ] Feature request: [Issue #X] - Description +- [ ] Feature request: [Issue #Y] - Description + +**Prioritization criteria:** +1. How many users request it? +2. Does it solve a real pain point? +3. Is it aligned with core mission? +4. Can it be implemented cleanly? + +--- + +## What NOT to Build + +**Features to avoid (scope creep):** + +❌ **Custom build pipeline** - Use existing tools (Webpack, Vite) +❌ **Theme marketplace** - Out of scope +❌ **Shopify store management** - Admin API apps do this +❌ **Theme development framework** - Focus on multi-shop workflows +❌ **Version control system** - Use Git, don't reinvent +❌ **Deployment platform** - Integrate, don't replace +❌ **Analytics dashboard** - Out of scope (use Shopify analytics) + +**Stick to the core mission:** Multi-shop workflow management + +--- + +## Success Metrics + +**Track these to validate feature impact:** + +**Usage Metrics:** +- npm downloads per week +- GitHub stars +- Issues opened (engagement) +- PRs from community + +**Feature Metrics (after release):** +- % of users using Campaign Tools +- % of shops with content protection enabled +- Number of health checks run +- Average shops per installation + +**Quality Metrics:** +- Test coverage (maintain 80%+) +- Issue close rate (<7 days average) +- PR merge time (<48 hours) +- Security audit score (maintain A+) + +--- + +## Version Planning + +**v2.3.0** (Nov 2025) - Campaign Tools + Content Protection +**v2.4.0** (Dec 2025) - DX Improvements + Testing +**v2.5.0** (Jan 2026) - Content Management + Collaboration +**v3.0.0** (Mar 2026) - Advanced Features (if breaking changes needed) + +--- + +## How to Use This Roadmap + +**For planning:** +1. Review quarterly +2. Adjust based on user feedback +3. Community can comment on features (GitHub Discussions) +4. Tag issues with version milestones + +**For contributors:** +- Pick features marked for current version +- Check "effort" estimate +- See acceptance criteria +- Reference user stories + +**For users:** +- See what's coming +- Vote on features (GitHub reactions on issues) +- Suggest new features +- Understand direction + +--- + +## Contributing to Roadmap + +**Have ideas? We want to hear them!** + +1. **Search existing issues** - Feature might already be requested +2. **Open discussion** - Discuss in GitHub Discussions first +3. **Create feature request** - Use issue template +4. **Vote on features** - React with 👍 on issues you want + +**Maintainer reviews quarterly** and updates roadmap based on: +- User requests +- Real pain points +- Alignment with mission +- Implementation feasibility + +--- + +**Roadmap is a living document** - Updated as we learn from users and the multi-shop ecosystem evolves. + +Last updated: 2025-10-28 by @brandt diff --git a/src/__tests__/unit/campaign-tools.test.ts b/src/__tests__/unit/campaign-tools.test.ts new file mode 100644 index 0000000..b913e1f --- /dev/null +++ b/src/__tests__/unit/campaign-tools.test.ts @@ -0,0 +1,370 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { CLIContext } from '../../lib/core/types.js'; + +// Mock @clack/prompts +vi.mock('@clack/prompts', () => ({ + select: vi.fn(), + text: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(), + note: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn(), + message: vi.fn(), + stop: vi.fn() + })) +})); + +// Mock child_process +vi.mock('child_process', () => ({ + execSync: vi.fn() +})); + +describe('campaign-tools', () => { + let mockContext: CLIContext; + + beforeEach(() => { + mockContext = { + deps: { + cwd: '/test/project', + shopsDir: '/test/project/shops', + credentialsDir: '/test/project/shops/credentials' + }, + shopOps: { + loadConfig: vi.fn(), + saveConfig: vi.fn(), + listShops: vi.fn(), + deleteShop: vi.fn() + }, + credOps: { + loadCredentials: vi.fn(), + saveCredentials: vi.fn() + }, + devOps: { + startDev: vi.fn() + } + }; + + vi.clearAllMocks(); + }); + + describe('handleCampaignTools', () => { + test('shows campaign tools menu', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue('create'); + vi.mocked(isCancel).mockReturnValue(true); // Cancel after menu shows + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + await handleCampaignTools(mockContext); + + // Assert - Menu was shown with options + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Select campaign tool:", + options: expect.arrayContaining([ + expect.objectContaining({ value: "create", label: "Create Promo Branch" }), + expect.objectContaining({ value: "push", label: "Push Promo to Main" }), + expect.objectContaining({ value: "end", label: "End Promo" }), + expect.objectContaining({ value: "list", label: "List Active Promos" }) + ]) + }) + ); + }); + + test('returns error when cancelled', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue('create'); + vi.mocked(isCancel).mockReturnValue(true); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain("No tool selected"); + }); + }); + + describe('Create Promo Branch', () => { + test('creates promo branch from shop main', async () => { + // Arrange + const { select, text, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(mockContext.shopOps.listShops).mockResolvedValue({ + success: true, + data: ['shop-a', 'shop-b'] + }); + + vi.mocked(select) + .mockResolvedValueOnce('create') // Campaign tool choice + .mockResolvedValueOnce('shop-a'); // Shop selection + + vi.mocked(text).mockResolvedValue('summer-sale'); + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockImplementation((cmd) => { + const command = typeof cmd === 'string' ? cmd : ''; + // Return strings (not Buffers) since code uses encoding: 'utf8' + if (command.includes('git rev-parse')) return 'sha123' as any; + if (command.includes('git checkout -b')) return 'Switched to branch' as any; + if (command.includes('git push')) return 'Branch pushed' as any; + if (command.includes('git branch --show-current')) return 'main' as any; + return '' as any; + }); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert - Test behavior, not implementation details + expect(result.success).toBe(true); + expect(execSync).toHaveBeenCalled(); // Verify git commands were called + const calls = vi.mocked(execSync).mock.calls.map(call => call[0]); + const callsStr = calls.join(' '); + expect(callsStr).toContain('shop-a/promo-summer-sale'); // Branch name used + }); + + test('validates promo name format', async () => { + // Arrange + const { select, text, isCancel } = await import('@clack/prompts'); + + vi.mocked(mockContext.shopOps.listShops).mockResolvedValue({ + success: true, + data: ['shop-a'] + }); + + vi.mocked(select) + .mockResolvedValueOnce('create') + .mockResolvedValueOnce('shop-a'); + + // Mock text to return invalid name + let validateFn: ((value: string) => string | undefined) | undefined; + vi.mocked(text).mockImplementation((config: any) => { + validateFn = config.validate; + return Promise.resolve('INVALID-NAME'); + }); + + vi.mocked(isCancel).mockReturnValue(false); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + await handleCampaignTools(mockContext); + + // Assert - Validation function rejects uppercase + expect(validateFn).toBeDefined(); + expect(validateFn?.('INVALID')).toBeTruthy(); // Should return error message + expect(validateFn?.('valid-promo-name')).toBeUndefined(); // Should accept valid name + }); + + test('shows error when base branch does not exist', async () => { + // Arrange + const { select, text, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(mockContext.shopOps.listShops).mockResolvedValue({ + success: true, + data: ['shop-a'] + }); + + vi.mocked(select) + .mockResolvedValueOnce('create') + .mockResolvedValueOnce('shop-a'); + + vi.mocked(text).mockResolvedValue('summer-sale'); + vi.mocked(isCancel).mockReturnValue(false); + + // Mock git rev-parse to fail (branch doesn't exist) + vi.mocked(execSync).mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd.includes('git rev-parse')) { + throw new Error('Branch not found'); + } + return Buffer.from(''); + }); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('Base branch'); + }); + }); + + describe('Push Promo to Main', () => { + test('creates PR from promo branch to shop main', async () => { + // Arrange + const { select, confirm, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('push'); + vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockImplementation((cmd) => { + const command = typeof cmd === 'string' ? cmd : ''; + if (command.includes('git branch --show-current')) { + return 'shop-a/promo-summer-sale' as any; + } + if (command.includes('gh pr create')) { + return 'PR created' as any; + } + return '' as any; + }); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('gh pr create --base shop-a/main --head shop-a/promo-summer-sale'), + expect.any(Object) + ); + }); + + test('rejects when not on promo branch', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('push'); + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockReturnValue('main' as any); // Not on promo branch + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain("Not on promo branch"); + }); + }); + + describe('End Promo', () => { + test('deletes promo branch after confirmation', async () => { + // Arrange + const { select, confirm, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('end'); + vi.mocked(confirm).mockResolvedValue(true); // Confirm deletion + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockImplementation((cmd) => { + const command = typeof cmd === 'string' ? cmd : ''; + if (command.includes('git branch --show-current')) { + return 'shop-a/promo-summer-sale' as any; + } + if (command.includes('git checkout')) return '' as any; + if (command.includes('git branch -D')) return '' as any; + if (command.includes('git push')) return '' as any; + return '' as any; + }); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(execSync).toHaveBeenCalledWith('git checkout shop-a/main'); + expect(execSync).toHaveBeenCalledWith('git branch -D shop-a/promo-summer-sale'); + expect(execSync).toHaveBeenCalledWith('git push origin --delete shop-a/promo-summer-sale'); + }); + + test('cancels when user declines confirmation', async () => { + // Arrange + const { select, confirm, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('end'); + vi.mocked(confirm).mockResolvedValue(false); // Decline deletion + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockReturnValue('shop-a/promo-summer-sale' as any); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain("Cancelled"); + }); + }); + + describe('List Active Promos', () => { + test('lists all promo branches', async () => { + // Arrange + const { select, isCancel, note } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('list'); + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockReturnValue(` + origin/shop-a/promo-summer-sale + origin/shop-a/main + origin/shop-b/promo-black-friday + origin/shop-b/staging + ` as any); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("2 active promo"), + expect.any(String) + ); + }); + + test('handles no active promos', async () => { + // Arrange + const { select, isCancel, note } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('list'); + vi.mocked(isCancel).mockReturnValue(false); + + vi.mocked(execSync).mockReturnValue(` + origin/shop-a/main + origin/shop-a/staging + origin/shop-b/main + ` as any); + + const { handleCampaignTools } = await import('../../lib/core/campaign-tools.js'); + + // Act + const result = await handleCampaignTools(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(note).toHaveBeenCalledWith( + "No active promo branches found", + expect.any(String) + ); + }); + }); +}); diff --git a/src/__tests__/unit/cli.test.ts b/src/__tests__/unit/cli.test.ts index 2e61371..c2d9dcd 100644 --- a/src/__tests__/unit/cli.test.ts +++ b/src/__tests__/unit/cli.test.ts @@ -231,6 +231,7 @@ describe('cli', () => { { value: 'list', label: 'List Shops', hint: 'View all shops' }, { value: 'create', label: 'Create New Shop', hint: 'Set up new shop' }, { value: 'edit', label: 'Edit Shop', hint: 'Update shop' }, + { value: 'campaign', label: 'Campaign Tools', hint: 'Manage promos and campaigns' }, { value: 'tools', label: 'Tools', hint: 'Sync shops and workflows' }, { value: 'exit', label: 'Exit', hint: 'Close manager' } ] diff --git a/src/lib/core/campaign-tools.ts b/src/lib/core/campaign-tools.ts new file mode 100644 index 0000000..ad570fb --- /dev/null +++ b/src/lib/core/campaign-tools.ts @@ -0,0 +1,298 @@ +import { select, isCancel, text, note, confirm, spinner } from "@clack/prompts"; +import { execSync } from "child_process"; +import type { CLIContext, Result } from "./types.js"; + +/** + * Campaign tools for managing promotional campaigns and time-based theme variations + * Implements Shopify's recommended branch-per-campaign workflow + */ + +export const handleCampaignTools = async (context: CLIContext): Promise> => { + const campaignChoice = await select({ + message: "Select campaign tool:", + options: [ + { value: "create", label: "Create Promo Branch", hint: "Start new campaign" }, + { value: "push", label: "Push Promo to Main", hint: "Merge campaign content back" }, + { value: "end", label: "End Promo", hint: "Cleanup after campaign" }, + { value: "list", label: "List Active Promos", hint: "Show all promo branches" } + ] + }); + + if (isCancel(campaignChoice)) { + return { success: false, error: "No tool selected" }; + } + + switch (campaignChoice) { + case "create": + return createPromoBranch(context); + case "push": + return pushPromoToMain(context); + case "end": + return endPromo(context); + case "list": + return listActivePromos(context); + default: + return { success: false, error: "Unknown tool" }; + } +}; + +const createPromoBranch = async (context: CLIContext): Promise> => { + note("Create a promo branch for a campaign or seasonal promotion", "🎯 Create Promo Branch"); + + // Select shop + const shopsResult = await context.shopOps.listShops(); + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet. Create shops first.", "⚠️ Error"); + return { success: false, error: "No shops configured" }; + } + + const shopId = await selectShop(shopsResult.data); + if (!shopId) return { success: false, error: "No shop selected" }; + + // Get promo name + const promoName = await text({ + message: "Promo campaign name:", + placeholder: "summer-sale, black-friday, holiday-2025", + validate: (value) => { + if (!value) return "Promo name is required"; + if (!/^[a-z0-9-]+$/.test(value)) return "Use lowercase letters, numbers, and hyphens only"; + return undefined; + } + }); + + if (isCancel(promoName)) return { success: false, error: "Cancelled" }; + + const branchName = `${shopId}/promo-${promoName}`; + const baseBranch = `${shopId}/main`; + + return createAndPushPromoBranch(branchName, baseBranch, shopId, promoName as string); +}; + +const createAndPushPromoBranch = async ( + branchName: string, + baseBranch: string, + shopId: string, + promoName: string +): Promise> => { + const s = spinner(); + + try { + s.start("Creating promo branch..."); + + // Check if base branch exists + try { + execSync(`git rev-parse --verify origin/${baseBranch}`, { stdio: 'ignore' }); + } catch { + s.stop("❌ Base branch not found"); + note(`Branch ${baseBranch} doesn't exist. Create the shop first.`, "⚠️ Error"); + return { success: false, error: `Base branch ${baseBranch} not found` }; + } + + // Create branch from shop/main + execSync(`git checkout -b ${branchName} origin/${baseBranch}`); + s.message("Branch created locally"); + + // Push to GitHub + execSync(`git push -u origin ${branchName}`); + s.stop("✅ Promo branch created and pushed"); + + displayPromoNextSteps(shopId, branchName, promoName); + + return { success: true }; + } catch (error) { + s.stop("❌ Failed to create promo branch"); + return { + success: false, + error: `Failed to create branch: ${error instanceof Error ? error.message : String(error)}` + }; + } +}; + +const pushPromoToMain = async (context: CLIContext): Promise> => { + note("Push promo campaign content back to shop main branch", "🔄 Push Promo to Main"); + + const currentBranch = getCurrentBranch(); + + // Validate we're on a promo branch + if (!currentBranch.includes('/promo-')) { + note(`You're not on a promo branch. Current: ${currentBranch}`, "⚠️ Error"); + return { success: false, error: "Not on promo branch" }; + } + + const shopId = currentBranch.split('/')[0]; + const targetBranch = `${shopId}/main`; + + const confirm = await confirmPushPromo(currentBranch, targetBranch); + if (!confirm) return { success: false, error: "Cancelled" }; + + return createPromoToMainPR(currentBranch, targetBranch); +}; + +const endPromo = async (_context: CLIContext): Promise> => { + note("End a promo campaign and cleanup", "🧹 End Promo"); + + const currentBranch = getCurrentBranch(); + + if (!currentBranch.includes('/promo-')) { + note("Not on a promo branch. Switch to promo branch first.", "⚠️ Error"); + return { success: false, error: "Not on promo branch" }; + } + + const confirmDelete = await confirm({ + message: `Delete branch ${currentBranch}? This can't be undone.`, + initialValue: false + }); + + if (isCancel(confirmDelete) || !confirmDelete) { + return { success: false, error: "Cancelled" }; + } + + const s = spinner(); + s.start("Cleaning up promo branch..."); + + try { + const shopMain = `${currentBranch.split('/')[0]}/main`; + + execSync(`git checkout ${shopMain}`); + s.message("Switched to shop main"); + + execSync(`git branch -D ${currentBranch}`); + s.message("Deleted local branch"); + + execSync(`git push origin --delete ${currentBranch}`); + s.stop("✅ Promo branch deleted"); + + note(`Branch ${currentBranch} has been deleted locally and on GitHub`, "✅ Cleanup Complete"); + + return { success: true }; + } catch (error) { + s.stop("❌ Cleanup failed"); + return { + success: false, + error: `Failed to cleanup: ${error instanceof Error ? error.message : String(error)}` + }; + } +}; + +const listActivePromos = async (context: CLIContext): Promise> => { + try { + const branches = execSync('git branch -r', { encoding: 'utf8' }) + .split('\n') + .map(b => b.trim()) + .filter(b => b.includes('/promo-')) + .map(b => b.replace('origin/', '')); + + if (branches.length === 0) { + note("No active promo branches found", "📋 Active Promos"); + return { success: true }; + } + + note(`Found ${branches.length} active promo branch${branches.length === 1 ? '' : 'es'}:`, "📋 Active Promos"); + + branches.forEach(branch => { + const [shop, promo] = branch.split('/promo-'); + console.log(`\n🎯 ${shop}/${promo}`); + console.log(` Branch: ${branch}`); + }); + + console.log(); + + return { success: true }; + } catch (error) { + return { + success: false, + error: `Failed to list promos: ${error instanceof Error ? error.message : String(error)}` + }; + } +}; + +// Helper functions +const selectShop = async (shops: string[]): Promise => { + const shopChoice = await select({ + message: "Select shop for promo:", + options: shops.map(shop => ({ value: shop, label: shop })) + }); + + return isCancel(shopChoice) ? null : String(shopChoice); +}; + +const getCurrentBranch = (): string => { + return execSync('git branch --show-current', { encoding: 'utf8' }).trim(); +}; + +const confirmPushPromo = async (from: string, to: string): Promise => { + const confirmPush = await confirm({ + message: `Create PR: ${from} → ${to}?`, + initialValue: true + }); + + return !isCancel(confirmPush) && Boolean(confirmPush); +}; + +const createPromoToMainPR = async (fromBranch: string, toBranch: string): Promise> => { + const s = spinner(); + + try { + s.start("Creating PR..."); + + const prTitle = `Deploy promo campaign: ${fromBranch.split('/promo-')[1]}`; + const prBody = `Merge promo campaign content from ${fromBranch} to ${toBranch}. + +This includes all customizations made during the campaign. + +**Review carefully:** This PR contains campaign-specific content that should be merged to keep ${toBranch} current.`; + + execSync( + `gh pr create --base ${toBranch} --head ${fromBranch} --title "${prTitle}" --body "${prBody}"`, + { stdio: 'pipe' } + ); + + s.stop("✅ PR created"); + note(`PR created: ${fromBranch} → ${toBranch}`, "✅ Success"); + + return { success: true }; + } catch (error) { + s.stop("❌ PR creation failed"); + + const manualInstructions = ` +Manual PR creation: + +GitHub CLI: + gh pr create --base ${toBranch} --head ${fromBranch} + +GitHub Web: + 1. Go to your repository + 2. Click "Pull requests" → "New pull request" + 3. Set base: ${toBranch}, compare: ${fromBranch} + 4. Create pull request +`; + + console.log(manualInstructions); + + return { success: true }; // Don't fail, just show manual instructions + } +}; + +const displayPromoNextSteps = (shopId: string, branchName: string, promoName: string): void => { + note("Promo branch created successfully!", "✅ Success"); + + console.log(`\n📋 Next Steps:\n`); + console.log(`1. Connect to Shopify Theme:`); + console.log(` - Shopify Admin → Themes → Add theme → Connect from GitHub`); + console.log(` - Select branch: ${branchName}`); + console.log(); + console.log(`2. Customize in Shopify Theme Editor:`); + console.log(` - Changes auto-sync back to ${branchName}`); + console.log(); + console.log(`3. Launch promo:`); + console.log(` - Publish the theme (or use Launchpad app for scheduling)`); + console.log(); + console.log(`4. After campaign ends:`); + console.log(` - Run: pnpm run shop → Campaign Tools → Push Promo to Main`); + console.log(` - This merges content back to ${shopId}/main`); + console.log(); + console.log(`5. Cleanup:`); + console.log(` - Run: pnpm run shop → Campaign Tools → End Promo`); + console.log(` - Deletes the ${branchName} branch`); + console.log(); +}; diff --git a/src/lib/core/cli.ts b/src/lib/core/cli.ts index d34171f..26650a0 100644 --- a/src/lib/core/cli.ts +++ b/src/lib/core/cli.ts @@ -4,12 +4,13 @@ import { createNewShop } from "./shop-creation.js"; import { startDevelopmentWorkflow } from "./dev-operations.js"; import { editShop } from "./shop-editing.js"; import { handleTools } from "./tools.js"; +import { handleCampaignTools } from "./campaign-tools.js"; /** * CLI interface for shop management using state machine pattern */ -type MenuAction = 'dev' | 'list' | 'create' | 'edit' | 'tools' | 'exit'; +type MenuAction = 'dev' | 'list' | 'create' | 'edit' | 'campaign' | 'tools' | 'exit'; export const runCLI = async (context: CLIContext): Promise => { intro("🚀 Multi-Shop Manager"); @@ -45,6 +46,7 @@ const showMainMenu = async (context: CLIContext): Promise => { { value: "list", label: "List Shops", hint: "View all shops" }, { value: "create", label: "Create New Shop", hint: "Set up new shop" }, { value: "edit", label: "Edit Shop", hint: "Update shop" }, + { value: "campaign", label: "Campaign Tools", hint: "Manage promos and campaigns" }, { value: "tools", label: "Tools", hint: "Sync shops and workflows" }, { value: "exit", label: "Exit", hint: "Close manager" } ] @@ -92,6 +94,9 @@ const executeMenuChoice = async (context: CLIContext, choice: MenuAction): Promi case "edit": await editShop(context); break; + case "campaign": + await handleCampaignTools(context); + break; case "tools": await handleTools(context); break; From ea52f1e75aa571180f3f85eb89b3b82133862eb1 Mon Sep 17 00:00:00 2001 From: Brandt Milczewski Date: Wed, 29 Oct 2025 10:40:31 -0700 Subject: [PATCH 2/3] Add content protection system --- CHANGELOG.md | 30 ++ src/__tests__/unit/content-protection.test.ts | 232 ++++++++++++++ src/__tests__/unit/global-settings.test.ts | 161 ++++++++++ src/__tests__/unit/shop-sync.test.ts | 16 +- src/lib/core/campaign-tools.ts | 8 +- src/lib/core/content-detection.ts | 127 +++++++- src/lib/core/content-protection.ts | 301 ++++++++++++++++++ src/lib/core/global-settings.ts | 61 ++++ src/lib/core/shop-sync.ts | 23 +- src/lib/core/tools.ts | 4 + src/types/shop.ts | 19 ++ 11 files changed, 965 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/unit/content-protection.test.ts create mode 100644 src/__tests__/unit/global-settings.test.ts create mode 100644 src/lib/core/content-protection.ts create mode 100644 src/lib/core/global-settings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb76711..835c61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ and this project adheres to ## [Unreleased] +## [2.3.0] - 2025-10-28 + +### Added + +- **Campaign Tools Menu** - Automated campaign/promo branch management + - Create Promo Branch: One-command promo branch creation from shop/main + - Push Promo to Main: Automated PR creation to merge campaign content back + - End Promo: Cleanup and delete campaign branches + - List Active Promos: Show all active campaign branches across shops + - Implements Shopify's recommended branch-per-campaign workflow + - 11 comprehensive tests +- **Content Protection System** - Config-based content overwrite prevention + - Per-shop content protection settings (strict/warn/off modes) + - Global settings for default protection behavior (settings.json) + - STRICT mode: Blocks cross-shop content sync, requires 'OVERRIDE' confirmation + - WARN mode: Shows warning, requires explicit confirmation + - Verbose/quiet verbosity controls + - Tools → Content Protection menu for configuration + - Show Protection Status for all shops + - Enable/Disable protection per shop or for all shops + - Smart detection: Only blocks cross-shop (main → shop-a), allows within-shop (shop-a → shop-a) + - 14 comprehensive tests + +### Changed + +- **Content detection improved** - Now enforces protection based on shop config + - Integrates with content protection settings + - Shows appropriate warnings based on protection mode + - Better UX messaging (explains protection is working, not broken) + ## [2.2.4] - 2025-10-28 ### Add npm package versioning badges to README.md diff --git a/src/__tests__/unit/content-protection.test.ts b/src/__tests__/unit/content-protection.test.ts new file mode 100644 index 0000000..2631626 --- /dev/null +++ b/src/__tests__/unit/content-protection.test.ts @@ -0,0 +1,232 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { CLIContext } from '../../lib/core/types.js'; +import type { ShopConfig } from '../../types/shop.js'; + +// Mock @clack/prompts +vi.mock('@clack/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(), + note: vi.fn() +})); + +describe('content-protection', () => { + let mockContext: CLIContext; + let mockShopConfig: ShopConfig; + + beforeEach(() => { + mockShopConfig = { + shopId: 'shop-a', + name: 'Shop A', + shopify: { + stores: { + production: { domain: 'shop-a.myshopify.com', branch: 'shop-a/main' }, + staging: { domain: 'staging-shop-a.myshopify.com', branch: 'shop-a/staging' } + }, + authentication: { method: 'theme-access-app' } + } + }; + + mockContext = { + deps: { + cwd: '/test/project', + shopsDir: '/test/project/shops', + credentialsDir: '/test/project/shops/credentials' + }, + shopOps: { + loadConfig: vi.fn().mockResolvedValue({ success: true, data: mockShopConfig }), + saveConfig: vi.fn().mockResolvedValue({ success: true }), + listShops: vi.fn().mockResolvedValue({ success: true, data: ['shop-a', 'shop-b'] }), + deleteShop: vi.fn() + }, + credOps: { + loadCredentials: vi.fn(), + saveCredentials: vi.fn() + }, + devOps: { + startDev: vi.fn() + } + }; + + vi.clearAllMocks(); + }); + + describe('handleContentProtection', () => { + test('shows protection menu with all options', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue(Symbol('cancel')); + vi.mocked(isCancel).mockReturnValue(true); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + await handleContentProtection(mockContext); + + // Assert + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Content Protection:", + options: expect.arrayContaining([ + expect.objectContaining({ value: "status", label: "Show Protection Status" }), + expect.objectContaining({ value: "configure", label: "Configure Shop Protection" }), + expect.objectContaining({ value: "enable-all", label: "Enable All Shops" }), + expect.objectContaining({ value: "disable-all", label: "Disable All Shops" }), + expect.objectContaining({ value: "global", label: "Global Settings" }) + ]) + }) + ); + }); + }); + + describe('Show Protection Status', () => { + test('displays protection status for all shops', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue('status'); + vi.mocked(isCancel).mockReturnValue(false); + + // Mock shop with protection enabled + vi.mocked(mockContext.shopOps.loadConfig).mockResolvedValue({ + success: true, + data: { + ...mockShopConfig, + contentProtection: { + enabled: true, + mode: 'strict', + verbosity: 'verbose' + } + } + }); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + const result = await handleContentProtection(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(mockContext.shopOps.listShops).toHaveBeenCalled(); + expect(mockContext.shopOps.loadConfig).toHaveBeenCalled(); + }); + + test('handles shops with no protection configured', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue('status'); + vi.mocked(isCancel).mockReturnValue(false); + + // Shop without contentProtection field + vi.mocked(mockContext.shopOps.loadConfig).mockResolvedValue({ + success: true, + data: mockShopConfig // No contentProtection + }); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + const result = await handleContentProtection(mockContext); + + // Assert + expect(result.success).toBe(true); + }); + }); + + describe('Configure Shop Protection', () => { + test('enables protection for a shop', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select) + .mockResolvedValueOnce('configure') // Menu choice + .mockResolvedValueOnce('shop-a') // Shop selection + .mockResolvedValueOnce(true) // Enable + .mockResolvedValueOnce('strict') // Mode + .mockResolvedValueOnce('verbose'); // Verbosity + + vi.mocked(isCancel).mockReturnValue(false); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + const result = await handleContentProtection(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(mockContext.shopOps.saveConfig).toHaveBeenCalledWith( + 'shop-a', + expect.objectContaining({ + contentProtection: { + enabled: true, + mode: 'strict', + verbosity: 'verbose' + } + }) + ); + }); + + test('disables protection for a shop', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select) + .mockResolvedValueOnce('configure') + .mockResolvedValueOnce('shop-a') + .mockResolvedValueOnce(false); // Disable + + vi.mocked(isCancel).mockReturnValue(false); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + const result = await handleContentProtection(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(mockContext.shopOps.saveConfig).toHaveBeenCalledWith( + 'shop-a', + expect.objectContaining({ + contentProtection: { + enabled: false, + mode: 'off', + verbosity: 'verbose' + } + }) + ); + }); + }); + + describe('Enable/Disable All Shops', () => { + test('enables protection for all shops', async () => { + // Arrange + const { select, confirm, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue('enable-all'); + vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(isCancel).mockReturnValue(false); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + const result = await handleContentProtection(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(mockContext.shopOps.saveConfig).toHaveBeenCalledTimes(2); // 2 shops + }); + + test('requires confirmation before enabling all', async () => { + // Arrange + const { select, confirm, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue('enable-all'); + vi.mocked(confirm).mockResolvedValue(false); // Decline + vi.mocked(isCancel).mockReturnValue(false); + + const { handleContentProtection } = await import('../../lib/core/content-protection.js'); + + // Act + const result = await handleContentProtection(mockContext); + + // Assert + expect(result.success).toBe(false); + expect(mockContext.shopOps.saveConfig).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/unit/global-settings.test.ts b/src/__tests__/unit/global-settings.test.ts new file mode 100644 index 0000000..f5d99dd --- /dev/null +++ b/src/__tests__/unit/global-settings.test.ts @@ -0,0 +1,161 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { createTempDir, cleanupTempDir } from '../helpers.js'; +import { loadGlobalSettings, saveGlobalSettings, getDefaultContentProtection } from '../../lib/core/global-settings.js'; +import type { GlobalSettings } from '../../types/shop.js'; +import fs from 'fs'; +import path from 'path'; + +describe('global-settings', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + describe('loadGlobalSettings', () => { + test('returns default settings when file does not exist', async () => { + // Act + const result = await loadGlobalSettings(tempDir); + + // Assert + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data?.contentProtection.defaultMode).toBe('strict'); + expect(result.data?.contentProtection.applyToNewShops).toBe(true); + }); + + test('loads existing settings from file', async () => { + // Arrange + const settings: GlobalSettings = { + contentProtection: { + defaultMode: 'warn', + defaultVerbosity: 'quiet', + applyToNewShops: false + }, + version: '1.0.0' + }; + + const settingsPath = path.join(tempDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + + // Act + const result = await loadGlobalSettings(tempDir); + + // Assert + expect(result.success).toBe(true); + expect(result.data?.contentProtection.defaultMode).toBe('warn'); + expect(result.data?.contentProtection.defaultVerbosity).toBe('quiet'); + expect(result.data?.contentProtection.applyToNewShops).toBe(false); + }); + + test('handles corrupted settings file', async () => { + // Arrange + const settingsPath = path.join(tempDir, 'settings.json'); + fs.writeFileSync(settingsPath, '{ invalid json }'); + + // Act + const result = await loadGlobalSettings(tempDir); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('saveGlobalSettings', () => { + test('saves settings to file', async () => { + // Arrange + const settings: GlobalSettings = { + contentProtection: { + defaultMode: 'strict', + defaultVerbosity: 'verbose', + applyToNewShops: true + }, + version: '1.0.0' + }; + + // Act + const result = await saveGlobalSettings(tempDir, settings); + + // Assert + expect(result.success).toBe(true); + + const settingsPath = path.join(tempDir, 'settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + + const savedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(savedSettings.contentProtection.defaultMode).toBe('strict'); + }); + + test('overwrites existing settings', async () => { + // Arrange + const oldSettings: GlobalSettings = { + contentProtection: { + defaultMode: 'off', + defaultVerbosity: 'quiet', + applyToNewShops: false + }, + version: '1.0.0' + }; + + await saveGlobalSettings(tempDir, oldSettings); + + const newSettings: GlobalSettings = { + contentProtection: { + defaultMode: 'strict', + defaultVerbosity: 'verbose', + applyToNewShops: true + }, + version: '1.0.0' + }; + + // Act + const result = await saveGlobalSettings(tempDir, newSettings); + + // Assert + expect(result.success).toBe(true); + + const settingsPath = path.join(tempDir, 'settings.json'); + const savedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(savedSettings.contentProtection.defaultMode).toBe('strict'); + }); + }); + + describe('getDefaultContentProtection', () => { + test('returns default values when no settings file', async () => { + // Act + const defaults = await getDefaultContentProtection(tempDir); + + // Assert + expect(defaults.defaultMode).toBe('strict'); + expect(defaults.defaultVerbosity).toBe('verbose'); + expect(defaults.applyToNewShops).toBe(true); + }); + + test('returns configured values when settings exist', async () => { + // Arrange + const settings: GlobalSettings = { + contentProtection: { + defaultMode: 'warn', + defaultVerbosity: 'quiet', + applyToNewShops: false + }, + version: '1.0.0' + }; + + await saveGlobalSettings(tempDir, settings); + + // Act + const defaults = await getDefaultContentProtection(tempDir); + + // Assert + expect(defaults.defaultMode).toBe('warn'); + expect(defaults.defaultVerbosity).toBe('quiet'); + expect(defaults.applyToNewShops).toBe(false); + }); + }); +}); diff --git a/src/__tests__/unit/shop-sync.test.ts b/src/__tests__/unit/shop-sync.test.ts index 2d0205b..c9ca40e 100644 --- a/src/__tests__/unit/shop-sync.test.ts +++ b/src/__tests__/unit/shop-sync.test.ts @@ -33,7 +33,21 @@ describe('shop-sync', () => { credentialsDir: '/test/project/shops/credentials' }, shopOps: { - loadConfig: vi.fn(), + loadConfig: vi.fn().mockResolvedValue({ + success: true, + data: { + shopId: 'shop-a', + name: 'Shop A', + shopify: { + stores: { + production: { domain: 'shop-a.myshopify.com', branch: 'shop-a/main' }, + staging: { domain: 'staging-shop-a.myshopify.com', branch: 'shop-a/staging' } + }, + authentication: { method: 'theme-access-app' } + } + // No contentProtection by default (protection disabled) + } + }), saveConfig: vi.fn(), listShops: vi.fn(), deleteShop: vi.fn() diff --git a/src/lib/core/campaign-tools.ts b/src/lib/core/campaign-tools.ts index ad570fb..e54bcc9 100644 --- a/src/lib/core/campaign-tools.ts +++ b/src/lib/core/campaign-tools.ts @@ -108,7 +108,7 @@ const createAndPushPromoBranch = async ( } }; -const pushPromoToMain = async (context: CLIContext): Promise> => { +const pushPromoToMain = async (_context: CLIContext): Promise> => { note("Push promo campaign content back to shop main branch", "🔄 Push Promo to Main"); const currentBranch = getCurrentBranch(); @@ -174,7 +174,7 @@ const endPromo = async (_context: CLIContext): Promise> => { } }; -const listActivePromos = async (context: CLIContext): Promise> => { +const listActivePromos = async (_context: CLIContext): Promise> => { try { const branches = execSync('git branch -r', { encoding: 'utf8' }) .split('\n') @@ -274,9 +274,9 @@ GitHub Web: }; const displayPromoNextSteps = (shopId: string, branchName: string, promoName: string): void => { - note("Promo branch created successfully!", "✅ Success"); + note(`Promo branch created: ${promoName}`, "✅ Success"); - console.log(`\n📋 Next Steps:\n`); + console.log(`\n📋 Next Steps for ${promoName} campaign:\n`); console.log(`1. Connect to Shopify Theme:`); console.log(` - Shopify Admin → Themes → Add theme → Connect from GitHub`); console.log(` - Select branch: ${branchName}`); diff --git a/src/lib/core/content-detection.ts b/src/lib/core/content-detection.ts index b858ada..9ffdcb2 100644 --- a/src/lib/core/content-detection.ts +++ b/src/lib/core/content-detection.ts @@ -1,8 +1,9 @@ import { execSync } from "child_process"; -import { select, isCancel, note } from "@clack/prompts"; +import { select, isCancel, note, text } from "@clack/prompts"; +import type { ShopConfig } from "../../types/shop.js"; /** - * Content file detection and warnings for shop sync operations + * Content file detection and protection enforcement * Prevents accidental overwriting of shop-specific content */ @@ -10,12 +11,18 @@ interface ContentCheckResult { readonly hasContentFiles: boolean; readonly shouldBlock: boolean; readonly syncType: 'cross-shop' | 'within-shop'; + readonly protectionMode?: 'strict' | 'warn' | 'off'; } /** - * Check if diff contains content files and determine warning level + * Check if diff contains content files and enforce protection + * @param shops List of shops being synced + * @param shopConfigs Shop configurations (for protection settings) */ -export const checkContentFiles = async (shops: string[]): Promise => { +export const checkContentFiles = async ( + shops: string[], + shopConfigs?: Map +): Promise => { try { const currentBranch = getCurrentBranch(); const shop = shops[0]; @@ -31,14 +38,26 @@ export const checkContentFiles = async (shops: string[]): Promise { console.log(); }; +const enforceContentProtection = async ( + contentFiles: string[], + allFiles: string[], + mode: 'strict' | 'warn' | 'off', + verbosity: 'verbose' | 'quiet' +): Promise => { + if (mode === 'off') { + return { hasContentFiles: true, shouldBlock: false, syncType: 'cross-shop', protectionMode: 'off' }; + } + + // Display warning based on verbosity + if (verbosity === 'verbose') { + displayProtectionWarning(contentFiles, allFiles, mode); + } + + if (mode === 'strict') { + // STRICT mode: Block unless override confirmed + const override = await requireOverrideConfirmation(); + return { + hasContentFiles: true, + shouldBlock: !override, + syncType: 'cross-shop', + protectionMode: 'strict' + }; + } + + if (mode === 'warn') { + // WARN mode: Show warning, require yes/no confirmation + const confirmed = await confirmSyncWithContentFiles(); + return { + hasContentFiles: true, + shouldBlock: !confirmed, + syncType: 'cross-shop', + protectionMode: 'warn' + }; + } + + return { hasContentFiles: true, shouldBlock: false, syncType: 'cross-shop', protectionMode: mode }; +}; + +const displayProtectionWarning = (contentFiles: string[], allFiles: string[], mode: 'strict' | 'warn'): void => { + console.log('\n'); + + if (mode === 'strict') { + note('❌ BLOCKED: Content Protection Enabled (STRICT mode)', '🛡️ PROTECTION'); + } else { + note('⚠️ WARNING: Content Protection Enabled (WARN mode)', '🛡️ PROTECTION'); + } + + console.log(`\nThis would overwrite shop-specific customizations:\n`); + contentFiles.forEach(file => console.log(` ⚠️ ${file}`)); + + const codeFiles = allFiles.filter(f => !contentFiles.includes(f)); + if (codeFiles.length > 0) { + console.log(`\n✅ Safe to merge (code files):\n`); + codeFiles.slice(0, 5).forEach(file => console.log(` ✅ ${file}`)); + if (codeFiles.length > 5) { + console.log(` ... and ${codeFiles.length - 5} more code files`); + } + } + + if (mode === 'strict') { + console.log(`\n🛡️ Content Protection is WORKING:`); + console.log(` • Your shop customizations are protected from being overwritten`); + console.log(` • This sync would replace shop-specific content with generic content`); + console.log(` • This is usually NOT what you want`); + console.log(); + console.log(`💡 What this means:`); + console.log(` - Code files (.liquid, .css, .js) will sync normally`); + console.log(` - Content files (settings, templates) will be BLOCKED`); + console.log(` - Your shop's branding and customizations stay safe`); + console.log(); + console.log(`⚠️ Only override if you're CERTAIN:`); + console.log(` - You want to replace shop content with main branch content`); + console.log(` - You understand shop customizations will be lost`); + console.log(` - This is an intentional content reset`); + } + + console.log(); +}; + +const requireOverrideConfirmation = async (): Promise => { + const override = await text({ + message: "Type 'OVERRIDE' to confirm (or cancel to abort):", + validate: (value) => { + if (!value) return "Type OVERRIDE or cancel"; + if (value !== 'OVERRIDE') return "Must type exactly: OVERRIDE"; + return undefined; + } + }); + + return !isCancel(override) && override === 'OVERRIDE'; +}; + const confirmSyncWithContentFiles = async (): Promise => { const confirm = await select({ message: "Continue creating PRs? (Review carefully before merging!)", diff --git a/src/lib/core/content-protection.ts b/src/lib/core/content-protection.ts new file mode 100644 index 0000000..ddfe20c --- /dev/null +++ b/src/lib/core/content-protection.ts @@ -0,0 +1,301 @@ +import { select, isCancel, note, confirm } from "@clack/prompts"; +import type { CLIContext, Result } from "./types.js"; +import type { ContentProtectionMode, ContentProtectionVerbosity, GlobalSettings } from "../../types/shop.js"; +import { loadGlobalSettings, saveGlobalSettings } from "./global-settings.js"; + +/** + * Content protection configuration and management + * Prevents accidental content overwrites when syncing across shops + */ + +export const handleContentProtection = async (context: CLIContext): Promise> => { + const protectionChoice = await select({ + message: "Content Protection:", + options: [ + { value: "status", label: "Show Protection Status", hint: "View all shops" }, + { value: "configure", label: "Configure Shop Protection", hint: "Enable/disable per shop" }, + { value: "enable-all", label: "Enable All Shops", hint: "Protect all shops" }, + { value: "disable-all", label: "Disable All Shops", hint: "Remove protection" }, + { value: "global", label: "Global Settings", hint: "Configure defaults" } + ] + }); + + if (isCancel(protectionChoice)) { + return { success: false, error: "Cancelled" }; + } + + switch (protectionChoice) { + case "status": + return showProtectionStatus(context); + case "configure": + return configureShopProtection(context); + case "enable-all": + return enableAllShops(context); + case "disable-all": + return disableAllShops(context); + case "global": + return configureGlobalSettings(context); + default: + return { success: false, error: "Unknown option" }; + } +}; + +const showProtectionStatus = async (context: CLIContext): Promise> => { + const shopsResult = await context.shopOps.listShops(); + + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet", "📋 Protection Status"); + return { success: true }; + } + + note("Content Protection Status:", "🛡️ Protection Status"); + + for (const shopId of shopsResult.data) { + const configResult = await context.shopOps.loadConfig(shopId); + + if (configResult.success && configResult.data) { + const protection = configResult.data.contentProtection; + const status = protection?.enabled + ? `✅ Enabled (${protection.mode} mode, ${protection.verbosity})` + : '❌ Disabled'; + + console.log(`\n ${shopId}: ${status}`); + } + } + + console.log(); + return { success: true }; +}; + +const configureShopProtection = async (context: CLIContext): Promise> => { + const shopsResult = await context.shopOps.listShops(); + + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet", "⚠️ Error"); + return { success: false, error: "No shops configured" }; + } + + const shopId = await selectShop(shopsResult.data); + if (!shopId) return { success: false, error: "No shop selected" }; + + const configResult = await context.shopOps.loadConfig(shopId); + if (!configResult.success || !configResult.data) { + return { success: false, error: "Failed to load shop config" }; + } + + const config = configResult.data; + const currentProtection = config.contentProtection; + + note(`Current: ${currentProtection?.enabled ? 'Enabled' : 'Disabled'}`, `🛡️ Configure ${shopId}`); + + const enableChoice = await select({ + message: "Content protection status:", + options: [ + { value: true, label: "Enable", hint: "Protect against content overwrites" }, + { value: false, label: "Disable", hint: "Allow content to sync" } + ] + }); + + if (isCancel(enableChoice)) return { success: false, error: "Cancelled" }; + + const enabled = Boolean(enableChoice); + + if (!enabled) { + const updatedConfig = { + ...config, + contentProtection: { + enabled: false, + mode: 'off' as ContentProtectionMode, + verbosity: 'verbose' as ContentProtectionVerbosity + } + }; + + await context.shopOps.saveConfig(shopId, updatedConfig); + note(`Content protection disabled for ${shopId}`, "✅ Updated"); + return { success: true }; + } + + // Configure mode + const mode = await selectMode(); + if (!mode) return { success: false, error: "Cancelled" }; + + const verbosity = await selectVerbosity(); + if (!verbosity) return { success: false, error: "Cancelled" }; + + const updatedConfig = { + ...config, + contentProtection: { + enabled: true, + mode, + verbosity + } + }; + + await context.shopOps.saveConfig(shopId, updatedConfig); + + note(`Content protection enabled for ${shopId} (${mode} mode, ${verbosity})`, "✅ Updated"); + + return { success: true }; +}; + +const enableAllShops = async (context: CLIContext): Promise> => { + const shopsResult = await context.shopOps.listShops(); + + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet", "⚠️ Error"); + return { success: false, error: "No shops configured" }; + } + + const confirmed = await confirm({ + message: `Enable strict content protection for all ${shopsResult.data.length} shops?`, + initialValue: true + }); + + if (isCancel(confirmed) || !confirmed) { + return { success: false, error: "Cancelled" }; + } + + let updated = 0; + + for (const shopId of shopsResult.data) { + const configResult = await context.shopOps.loadConfig(shopId); + + if (configResult.success && configResult.data) { + const updatedConfig = { + ...configResult.data, + contentProtection: { + enabled: true, + mode: 'strict' as ContentProtectionMode, + verbosity: 'verbose' as ContentProtectionVerbosity + } + }; + + await context.shopOps.saveConfig(shopId, updatedConfig); + updated++; + } + } + + note(`Enabled content protection for ${updated} shop${updated === 1 ? '' : 's'}`, "✅ Success"); + + return { success: true }; +}; + +const disableAllShops = async (context: CLIContext): Promise> => { + const shopsResult = await context.shopOps.listShops(); + + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet", "⚠️ Error"); + return { success: false, error: "No shops configured" }; + } + + const confirmed = await confirm({ + message: `Disable content protection for all ${shopsResult.data.length} shops? This removes safety checks.`, + initialValue: false + }); + + if (isCancel(confirmed) || !confirmed) { + return { success: false, error: "Cancelled" }; + } + + let updated = 0; + + for (const shopId of shopsResult.data) { + const configResult = await context.shopOps.loadConfig(shopId); + + if (configResult.success && configResult.data) { + const updatedConfig = { + ...configResult.data, + contentProtection: { + enabled: false, + mode: 'off' as ContentProtectionMode, + verbosity: 'verbose' as ContentProtectionVerbosity + } + }; + + await context.shopOps.saveConfig(shopId, updatedConfig); + updated++; + } + } + + note(`Disabled content protection for ${updated} shop${updated === 1 ? '' : 's'}`, "✅ Success"); + + return { success: true }; +}; + +const configureGlobalSettings = async (context: CLIContext): Promise> => { + const settingsResult = await loadGlobalSettings(context.deps.cwd); + const currentSettings = settingsResult.data || { + contentProtection: { + defaultMode: 'strict' as ContentProtectionMode, + defaultVerbosity: 'verbose' as ContentProtectionVerbosity, + applyToNewShops: true + }, + version: '1.0.0' + }; + + const current = currentSettings.contentProtection; + note(`Current: ${current.defaultMode} mode, ${current.defaultVerbosity}, ${current.applyToNewShops ? 'auto-apply' : 'manual'}`, "⚙️ Global Settings"); + + const mode = await selectMode(); + if (!mode) return { success: false, error: "Cancelled" }; + + const verbosity = await selectVerbosity(); + if (!verbosity) return { success: false, error: "Cancelled" }; + + const applyToNew = await confirm({ + message: "Apply to new shops automatically?", + initialValue: true + }); + + if (isCancel(applyToNew)) return { success: false, error: "Cancelled" }; + + const newSettings: GlobalSettings = { + contentProtection: { + defaultMode: mode, + defaultVerbosity: verbosity, + applyToNewShops: Boolean(applyToNew) + }, + version: '1.0.0' + }; + + await saveGlobalSettings(context.deps.cwd, newSettings); + + note("Global settings updated. New shops will use these defaults.", "✅ Updated"); + + return { success: true }; +}; + +// Helper functions +const selectShop = async (shops: string[]): Promise => { + const shopChoice = await select({ + message: "Select shop to configure:", + options: shops.map(shop => ({ value: shop, label: shop })) + }); + + return isCancel(shopChoice) ? null : String(shopChoice); +}; + +const selectMode = async (): Promise => { + const modeChoice = await select({ + message: "Protection mode:", + options: [ + { value: "strict", label: "Strict", hint: "Block cross-shop content sync" }, + { value: "warn", label: "Warn", hint: "Show warning, require confirmation" }, + { value: "off", label: "Off", hint: "No protection" } + ] + }); + + return isCancel(modeChoice) ? null : modeChoice as ContentProtectionMode; +}; + +const selectVerbosity = async (): Promise => { + const verbosityChoice = await select({ + message: "Output verbosity:", + options: [ + { value: "verbose", label: "Verbose", hint: "Show all details (recommended)" }, + { value: "quiet", label: "Quiet", hint: "Minimal output" } + ] + }); + + return isCancel(verbosityChoice) ? null : verbosityChoice as ContentProtectionVerbosity; +}; diff --git a/src/lib/core/global-settings.ts b/src/lib/core/global-settings.ts new file mode 100644 index 0000000..54c54c7 --- /dev/null +++ b/src/lib/core/global-settings.ts @@ -0,0 +1,61 @@ +import fs from 'fs'; +import path from 'path'; +import type { GlobalSettings } from '../../types/shop.js'; +import type { Result } from './types.js'; + +/** + * Global settings operations for multi-shop configuration + */ + +const DEFAULT_SETTINGS: GlobalSettings = { + contentProtection: { + defaultMode: 'strict', + defaultVerbosity: 'verbose', + applyToNewShops: true + }, + version: '1.0.0' +}; + +export const loadGlobalSettings = async (cwd: string): Promise> => { + try { + const settingsPath = path.join(cwd, 'settings.json'); + + if (!fs.existsSync(settingsPath)) { + return { success: true, data: DEFAULT_SETTINGS }; + } + + const rawSettings = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(rawSettings) as GlobalSettings; + + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: `Failed to load global settings: ${error instanceof Error ? error.message : String(error)}` + }; + } +}; + +export const saveGlobalSettings = async (cwd: string, settings: GlobalSettings): Promise> => { + try { + const settingsPath = path.join(cwd, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: `Failed to save global settings: ${error instanceof Error ? error.message : String(error)}` + }; + } +}; + +export const getDefaultContentProtection = async (cwd: string): Promise => { + const settingsResult = await loadGlobalSettings(cwd); + + if (!settingsResult.success || !settingsResult.data) { + return DEFAULT_SETTINGS.contentProtection; + } + + return settingsResult.data.contentProtection; +}; diff --git a/src/lib/core/shop-sync.ts b/src/lib/core/shop-sync.ts index bd02d7a..ca26d3b 100644 --- a/src/lib/core/shop-sync.ts +++ b/src/lib/core/shop-sync.ts @@ -23,7 +23,7 @@ export const syncShops = async (context: CLIContext): Promise> => { const prTitle = await getPRTitle(); if (!prTitle) return { success: false, error: "No PR title provided" }; - return createShopSyncPRs(selectedShops, prTitle); + return createShopSyncPRs(selectedShops, prTitle, context); }; const selectShopsToSync = async (shops: string[]): Promise => { @@ -49,11 +49,24 @@ const getPRTitle = async (): Promise => { return isCancel(prTitle) ? null : prTitle as string; }; -const createShopSyncPRs = async (selectedShops: string[], title: string): Promise> => { - // Check for content file changes before creating PRs - const contentCheck = await checkContentFiles(selectedShops); +const createShopSyncPRs = async ( + selectedShops: string[], + title: string, + context: CLIContext +): Promise> => { + // Load shop configs for protection settings + const shopConfigs = new Map(); + for (const shopId of selectedShops) { + const configResult = await context.shopOps.loadConfig(shopId); + if (configResult.success && configResult.data) { + shopConfigs.set(shopId, configResult.data); + } + } + + // Check for content file changes and enforce protection + const contentCheck = await checkContentFiles(selectedShops, shopConfigs); if (contentCheck.shouldBlock) { - return { success: false, error: "Sync cancelled by user" }; + return { success: false, error: "Sync cancelled - content protection active" }; } const s = spinner(); diff --git a/src/lib/core/tools.ts b/src/lib/core/tools.ts index 7d205a8..2d3a0b0 100644 --- a/src/lib/core/tools.ts +++ b/src/lib/core/tools.ts @@ -3,6 +3,7 @@ import type { CLIContext, Result } from "./types.js"; import { syncShops } from "./shop-sync.js"; import { linkThemes } from "./theme-linking.js"; import { checkVersions } from "./version-check.js"; +import { handleContentProtection } from "./content-protection.js"; /** * Tools menu coordination @@ -13,6 +14,7 @@ export const handleTools = async (context: CLIContext): Promise> => message: "Select tool:", options: [ { value: "sync", label: "Sync Shops", hint: "Create PRs to deploy main branch changes to shops" }, + { value: "protection", label: "Content Protection", hint: "Configure content protection per shop" }, { value: "themes", label: "Link Themes", hint: "Connect Git branches to Shopify themes" }, { value: "versions", label: "Version Check", hint: "Check versions of important packages" } ] @@ -23,6 +25,8 @@ export const handleTools = async (context: CLIContext): Promise> => switch (toolChoice) { case "sync": return syncShops(context); + case "protection": + return handleContentProtection(context); case "themes": return linkThemes(context); case "versions": diff --git a/src/types/shop.ts b/src/types/shop.ts index 24ba9f8..7d161ea 100644 --- a/src/types/shop.ts +++ b/src/types/shop.ts @@ -7,6 +7,25 @@ export interface ShopConfig { readonly name: string; readonly shopify: ShopifyConfig; readonly metadata?: ShopMetadata; + readonly contentProtection?: ContentProtectionConfig; +} + +export interface ContentProtectionConfig { + readonly enabled: boolean; + readonly mode: ContentProtectionMode; + readonly verbosity: ContentProtectionVerbosity; +} + +export type ContentProtectionMode = 'strict' | 'warn' | 'off'; +export type ContentProtectionVerbosity = 'verbose' | 'quiet'; + +export interface GlobalSettings { + readonly contentProtection: { + readonly defaultMode: ContentProtectionMode; + readonly defaultVerbosity: ContentProtectionVerbosity; + readonly applyToNewShops: boolean; + }; + readonly version: string; } export interface ShopifyConfig { From 2791e982feefbc6e16fccc97308b420308fdb433 Mon Sep 17 00:00:00 2001 From: Brandt Milczewski Date: Wed, 29 Oct 2025 15:18:27 -0700 Subject: [PATCH 3/3] Add more docs and health checks --- CHANGELOG.md | 9 + README.md | 106 ++++- docs/QUICK-REFERENCE.md | 50 ++- docs/README.md | 19 +- docs/guides/getting-started.md | 46 ++- docs/guides/security-guide.md | 162 ++++++++ src/__tests__/unit/shop-health-check.test.ts | 290 +++++++++++++ src/lib/core/shop-health-check.ts | 403 +++++++++++++++++++ src/lib/core/tools.ts | 4 + 9 files changed, 1059 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/unit/shop-health-check.test.ts create mode 100644 src/lib/core/shop-health-check.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 835c61f..76ac2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ and this project adheres to - Enable/Disable protection per shop or for all shops - Smart detection: Only blocks cross-shop (main → shop-a), allows within-shop (shop-a → shop-a) - 14 comprehensive tests +- **Shop Health Check** - Diagnostic tool for verifying shop setup + - Check single shop or all shops + - Configuration validation (JSON, domains, branches) + - Credentials verification (file exists, tokens present, permissions) + - Git branch validation (existence, sync status) + - Content protection status display + - Actionable recommendations for issues + - Informational only (no auto-fix, always exits successfully) + - 8 comprehensive tests ### Changed diff --git a/README.md b/README.md index 95dfd40..c429691 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,14 @@ themes, or any existing Shopify theme that needs multi-shop capabilities. - **⚡ Modern GitHub Flow** - Simple, PR-based development workflow - **🧪 Interactive Testing** - Test against real Shopify preview themes -### 🛡️ Built-In Safeguards (v2.1.0+) - -- **🚨 Content Detection** - Warns before overwriting shop-specific settings +### 🛡️ Built-In Safeguards + +- **🚨 Content Protection** (v2.3.0+) - Config-based prevention of content + overwrites with strict/warn/off modes +- **🏥 Health Check** (v2.3.0+) - Comprehensive diagnostics for configuration, + credentials, and branches +- **🎯 Campaign Tools** (v2.3.0+) - Automated campaign lifecycle management with + one-command promo workflows - **🔒 Security Audit** - `multi-shop audit` command checks permissions and credentials - **✅ Tests** - Unit, integration, security, E2E, and performance tests @@ -263,26 +268,99 @@ When you merge features to main: 3. **Shop teams create final PRs**: `shop-a/staging → shop-a/main`, `shop-b/staging → shop-b/main`, etc. -### Campaign Management (Per Shop) +### Campaign Management (Per Shop) - v2.3.0+ + +**New Campaign Tools Menu** automates the entire campaign lifecycle: ```bash -# Create promo for specific shop +# 1. Create promo branch (one command) pnpm run shop → Campaign Tools → Create Promo Branch # → Select shop: shop-a # → Promo name: summer-sale -# → Creates: shop-a/promo-summer-sale +# → Automatically creates and pushes: shop-a/promo-summer-sale -# Connect promo theme in Shopify admin (shop-a only) +# 2. Connect promo theme in Shopify admin # → Add theme → Connect from GitHub → shop-a/promo-summer-sale -# Launch promo (shop-a only) +# 3. Customize in Shopify Theme Editor +# → Changes auto-sync back to promo branch + +# 4. Launch promo # → Publish theme or use Launchpad app -# Push content back to shop main (keeps shop-a/main current) +# 5. Push content back to main (one command) pnpm run shop → Campaign Tools → Push Promo to Main +# → Select promo: shop-a/promo-summer-sale # → Creates PR: shop-a/promo-summer-sale → shop-a/main + +# 6. List all active campaigns +pnpm run shop → Campaign Tools → List Active Promos +# → Shows all promo branches across all shops + +# 7. Clean up after campaign +pnpm run shop → Campaign Tools → End Promo +# → Select and delete finished promo branch +``` + +**Content Protection Integration:** Campaign content merges respect your Content +Protection settings, ensuring intentional content changes. + +### Content Protection (v2.3.0+) + +**Config-based safeguards** prevent accidental content overwrites: + +```bash +# View protection status +pnpm run shop → Tools → Content Protection → Show Protection Status + +# Configure individual shop +pnpm run shop → Tools → Content Protection → Configure Shop Protection +# → Select shop +# → Enable/Disable +# → Choose mode: strict (block), warn (confirm), or off +# → Choose verbosity: verbose or quiet + +# Enable protection for all shops +pnpm run shop → Tools → Content Protection → Enable All Shops + +# Configure global defaults +pnpm run shop → Tools → Content Protection → Global Settings +``` + +**Three Protection Modes:** + +- **Strict** - Blocks cross-shop content syncs, requires 'OVERRIDE' to proceed +- **Warn** - Shows warning with file list, requires confirmation (default) +- **Off** - No protection, content syncs freely + +**Smart Detection:** Distinguishes between risky cross-shop operations +(`main → shop-a`) and safe within-shop operations +(`shop-a/main → shop-a/staging`). + +### Health Check (v2.3.0+) + +**Diagnostic tool** verifies your shop configuration: + +```bash +# Check single shop (detailed) +pnpm run shop → Tools → Health Check → Check Single Shop +# → Verifies: configuration, credentials, branches, content protection + +# Check all shops (quick overview) +pnpm run shop → Tools → Health Check → Check All Shops +# → Shows status for every configured shop ``` +**What it checks:** + +- Configuration file validity (JSON, required fields, domains) +- Credentials existence, tokens presence, file permissions +- Git branch existence and sync status +- Content Protection status and settings + +**Actionable recommendations** without auto-fixing - tells you exactly what +commands to run. + --- ## 📋 Development Workflow @@ -478,6 +556,14 @@ git branch --show-current # Should be: feature/name or shop-a/name for auto-detection ``` +**"Not sure what's wrong?" - Run Health Check (v2.3.0+)** + +```bash +pnpm run shop → Tools → Health Check +# Comprehensive diagnostics with actionable recommendations +# Checks: config, credentials, branches, content protection +``` + --- ## 📚 Documentation @@ -517,7 +603,7 @@ npm publish ## 📄 License -MIT © [ShopDevs](https://shopdevs.com) +MIT © [Brandt Milczewski](https://github.com/brandtam) --- diff --git a/docs/QUICK-REFERENCE.md b/docs/QUICK-REFERENCE.md index 58a31b6..a908e02 100644 --- a/docs/QUICK-REFERENCE.md +++ b/docs/QUICK-REFERENCE.md @@ -45,16 +45,19 @@ pnpm run shop → Edit Shop pnpm run shop → Delete Shop ``` -### Campaign Tools +### Campaign Tools (NEW in v2.3.0) ```bash -# Create promo branch +# Create promo branch (shop-a/main → shop-a/promo-NAME) pnpm run shop → Campaign Tools → Create Promo Branch -# Push promo to main +# List all active campaign branches +pnpm run shop → Campaign Tools → List Active Promos + +# Push promo content back to shop main pnpm run shop → Campaign Tools → Push Promo to Main -# End promo campaign +# End campaign and cleanup pnpm run shop → Campaign Tools → End Promo ``` @@ -64,11 +67,20 @@ pnpm run shop → Campaign Tools → End Promo # Sync shops (create PRs) pnpm run shop → Tools → Sync Shops -# Link themes +# Verify shop configuration +pnpm run shop → Tools → Health Check + +# Configure content protection +pnpm run shop → Tools → Content Protection + +# Link themes to GitHub pnpm run shop → Tools → Link Themes -# Security audit -pnpm run shop → Tools → Security Audit +# Check package versions +pnpm run shop → Tools → Version Check + +# Run security audit +npx multi-shop audit ``` ## Configuration Files @@ -94,10 +106,29 @@ pnpm run shop → Tools → Security Audit "authentication": { "method": "theme-access-app" } + }, + "contentProtection": { + "enabled": true, + "mode": "strict", + "verbosity": "verbose" } } ``` +### Global Settings (Root) + +```json +// settings.json (NEW in v2.3.0) +{ + "contentProtection": { + "defaultMode": "strict", + "defaultVerbosity": "verbose", + "applyToNewShops": true + }, + "version": "1.0.0" +} +``` + ### Developer Credentials (Local Only) ```json @@ -163,8 +194,11 @@ pnpm run shop → Campaign Tools → Create Promo Branch # 4. Launch promo # Publish theme or use Launchpad app -# 5. Push content back to main +# 5. After campaign: Push content back to main pnpm run shop → Campaign Tools → Push Promo to Main + +# 6. Cleanup campaign branch +pnpm run shop → Campaign Tools → End Promo ``` ## Branch Naming diff --git a/docs/README.md b/docs/README.md index 9efee27..603eaa5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,9 @@ ShopDevs Multi-Shop transforms any Shopify theme into a sophisticated multi-shop - **Contextual Development** - One command adapts to your branch context - **Automated Shop Syncing** - PRs auto-created when main updates +- **Campaign Tools** (v2.3.0+) - One-command campaign lifecycle management +- **Content Protection** (v2.3.0+) - Config-based safeguards against content overwrites +- **Health Check** (v2.3.0+) - Diagnostic tool for shop configuration verification - **Secure Credentials** - Developer-specific tokens stored locally only - **Shop Isolation** - Complete separation between shop customizations - **Modern GitHub Flow** - Simple, PR-based development workflow @@ -179,15 +182,17 @@ Build features for one shop only: 3. Create PR to shop's main branch 4. Deploy to that shop only -### Campaign Management +### Campaign Management (v2.3.0+) -Run promotions and campaigns: +Run promotions and campaigns with automated tools: -1. Create promo branch from shop main -2. Connect promo theme in Shopify -3. Customize in Shopify admin (auto-syncs) -4. Launch promo theme -5. Push content back to shop main +1. **Create Promo Branch** - One command creates campaign branch +2. **Connect to Shopify** - Link branch to preview theme +3. **Customize** - Edit in Shopify admin (auto-syncs) +4. **Launch** - Publish campaign theme +5. **Push to Main** - One command creates PR to merge content back +6. **List Promos** - See all active campaigns across shops +7. **End Promo** - Clean up after campaign completes ## Architecture diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 8df6140..5cf1ccd 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -349,11 +349,13 @@ pnpm run shop → Edit Shop # Delete shop pnpm run shop → Delete Shop -# Campaign tools +# Campaign tools (v2.3.0+) pnpm run shop → Campaign Tools +# → Create Promo Branch, Push Promo to Main, List Active Promos, End Promo # Tools menu pnpm run shop → Tools +# → Sync Shops, Content Protection, Health Check (v2.3.0+) ``` ### Direct npx Commands @@ -440,12 +442,16 @@ pnpm run dev gh pr create --base shop-a/main --title "Add calculator" ``` -### Campaign/Promo +### Campaign/Promo Workflow + +**Quick Campaign Management with Campaign Tools (v2.3.0+):** ```bash -# 1. Create promo branch +# 1. Create promo branch automatically pnpm run shop → Campaign Tools → Create Promo Branch -# → shop-a → promo name: summer-sale +# → Select shop: shop-a +# → Promo name: summer-sale +# → Branch created: shop-a/promo-summer-sale # 2. Connect to Shopify theme # Shopify Admin → Add theme → Connect from GitHub → shop-a/promo-summer-sale @@ -456,10 +462,22 @@ pnpm run shop → Campaign Tools → Create Promo Branch # 4. Launch promo # Publish theme or use Launchpad app -# 5. Push to main (keeps main current) +# 5. Push content back to main (one command) pnpm run shop → Campaign Tools → Push Promo to Main +# → Select promo: shop-a/promo-summer-sale +# → Creates PR: shop-a/promo-summer-sale → shop-a/main + +# 6. List all active campaigns +pnpm run shop → Campaign Tools → List Active Promos +# → Shows all promo branches across all shops + +# 7. Clean up after campaign (optional) +pnpm run shop → Campaign Tools → End Promo +# → Select promo to delete ``` +**Note on Content Protection:** If you have Content Protection enabled (strict mode), pushing promo content to main will detect content file changes and warn you appropriately. This is normal for campaign workflows - the protection ensures you're intentionally merging customized content. + ## Troubleshooting ### "No shops configured yet" @@ -509,6 +527,24 @@ chmod 600 shops/credentials/*.credentials.json 2. Push if needed: `git push -u origin shop-a/main` 3. Verify GitHub connection in Shopify Admin → Settings +### Run Health Check (v2.3.0+) + +**Not sure what's wrong?** Use the built-in health check: + +```bash +pnpm run shop → Tools → Health Check +# → Check Single Shop (detailed diagnostics) +# → Check All Shops (quick overview) +``` + +The health check verifies: +- Configuration file validity (JSON syntax, required fields) +- Credentials existence and permissions +- Git branch existence and sync status +- Content Protection status + +It provides actionable recommendations without auto-fixing anything. + ## Next Steps Now that you're set up, explore: diff --git a/docs/guides/security-guide.md b/docs/guides/security-guide.md index f6fe31a..8f8b9e3 100644 --- a/docs/guides/security-guide.md +++ b/docs/guides/security-guide.md @@ -210,6 +210,168 @@ Works securely across all platforms: - Path separator handling - Drive letter validation +## Content Protection System (v2.3.0+) + +### What is Content Protection? + +Content Protection prevents accidental overwrites of shop-specific customizations when syncing code changes across shops. It's a config-based safety system that detects content file modifications and blocks or warns before potentially destructive operations. + +**Content files include:** +- `config/settings_data.json` - Theme settings (colors, fonts, layouts) +- `templates/*.json` - Page layouts and section configurations +- `locales/*.json` - Translations and text content + +### Three Protection Modes + +**Strict Mode** (recommended for production): +```bash +# Blocks cross-shop content syncs completely +# Requires typing 'OVERRIDE' to proceed +# Use when you want maximum protection +``` + +**Warn Mode** (default): +```bash +# Shows warning with content file list +# Requires confirmation (y/n) +# Good balance of safety and flexibility +``` + +**Off Mode**: +```bash +# No protection, content syncs freely +# Use for testing or when protection not needed +``` + +### Smart Cross-Shop Detection + +Content Protection uses smart detection to distinguish between risky cross-shop operations and safe within-shop operations: + +**Cross-Shop Sync (BLOCKED in strict mode):** +```bash +main → shop-a/staging # ⛔ Different shops, strict protection +feature/test → shop-b/main # ⛔ Different shops, strict protection +``` + +**Within-Shop Sync (INFO only):** +```bash +shop-a/main → shop-a/staging # ✅ Same shop, just informational +shop-a/promo → shop-a/main # ✅ Same shop, just informational +my-store/dev → my-store/test # ✅ Same shop, just informational +``` + +This works for ANY shop name pattern - the system automatically detects the shop prefix and determines context. + +### Configuration + +**Per-Shop Settings:** + +Each shop can have its own content protection settings in `shops/{shopId}.config.json`: + +```json +{ + "shopId": "shop-a", + "name": "Shop A", + "contentProtection": { + "enabled": true, + "mode": "strict", + "verbosity": "verbose" + } +} +``` + +**Global Defaults:** + +Set defaults for all shops in `shops/settings.json`: + +```json +{ + "contentProtection": { + "defaultMode": "warn", + "defaultVerbosity": "verbose" + } +} +``` + +### Using Content Protection Tools + +**View Protection Status:** +```bash +pnpm run shop → Tools → Content Protection → Show Protection Status +# Shows protection status for all shops +``` + +**Configure Individual Shop:** +```bash +pnpm run shop → Tools → Content Protection → Configure Shop Protection +# → Select shop +# → Enable/Disable +# → Choose mode (strict/warn/off) +# → Choose verbosity (verbose/quiet) +``` + +**Enable All Shops:** +```bash +pnpm run shop → Tools → Content Protection → Enable All Shops +# Enables protection for every shop +``` + +**Disable All Shops:** +```bash +pnpm run shop → Tools → Content Protection → Disable All Shops +# Removes protection from every shop +``` + +**Configure Global Defaults:** +```bash +pnpm run shop → Tools → Content Protection → Global Settings +# Set default mode and verbosity for new shops +``` + +### Bypassing Strict Mode + +When you encounter strict mode protection and need to proceed: + +```bash +🚨 STRICT MODE: Content Protection Enabled + +The following content files would be modified: + - config/settings_data.json + - templates/index.json + +Type 'OVERRIDE' to bypass protection: OVERRIDE +``` + +This ensures you're making an intentional decision to sync content files. + +### Best Practices + +1. **Enable strict mode for production shops** - Maximum protection +2. **Use warn mode for staging** - Balance of safety and speed +3. **Review content files carefully** before syncing cross-shop +4. **Use within-shop syncs freely** - Protection is smart about context +5. **Configure global defaults** - Consistent protection across team + +### Troubleshooting Content Protection + +**"Content protection blocking my sync"** +- Check if it's a cross-shop sync (main → shop-a) vs within-shop (shop-a → shop-a) +- Review the content files listed - are they actually shop-specific? +- Consider changing mode to 'warn' if strict is too aggressive +- Type 'OVERRIDE' if you're certain the sync is safe + +**"I want to disable protection temporarily"** +```bash +pnpm run shop → Tools → Content Protection → Configure Shop Protection +# → Select shop → Disable +``` + +**"How do I know if protection is working?"** +```bash +pnpm run shop → Tools → Health Check → Check Single Shop +# Shows content protection status in health report +``` + ## Security Audit Run security audits to verify credential safety: diff --git a/src/__tests__/unit/shop-health-check.test.ts b/src/__tests__/unit/shop-health-check.test.ts new file mode 100644 index 0000000..ace3866 --- /dev/null +++ b/src/__tests__/unit/shop-health-check.test.ts @@ -0,0 +1,290 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { CLIContext } from '../../lib/core/types.js'; +import type { ShopConfig } from '../../types/shop.js'; + +// Mock @clack/prompts +vi.mock('@clack/prompts', () => ({ + select: vi.fn(), + isCancel: vi.fn(), + note: vi.fn() +})); + +// Mock child_process +vi.mock('child_process', () => ({ + execSync: vi.fn() +})); + +describe('shop-health-check', () => { + let mockContext: CLIContext; + let mockShopConfig: ShopConfig; + + beforeEach(() => { + mockShopConfig = { + shopId: 'shop-a', + name: 'Shop A', + shopify: { + stores: { + production: { domain: 'shop-a.myshopify.com', branch: 'shop-a/main' }, + staging: { domain: 'staging-shop-a.myshopify.com', branch: 'shop-a/staging' } + }, + authentication: { method: 'theme-access-app' } + } + }; + + mockContext = { + deps: { + cwd: '/test/project', + shopsDir: '/test/project/shops', + credentialsDir: '/test/project/shops/credentials' + }, + shopOps: { + loadConfig: vi.fn().mockResolvedValue({ success: true, data: mockShopConfig }), + saveConfig: vi.fn(), + listShops: vi.fn().mockResolvedValue({ success: true, data: ['shop-a', 'shop-b'] }), + deleteShop: vi.fn() + }, + credOps: { + loadCredentials: vi.fn().mockResolvedValue({ + success: true, + data: { + developer: 'test-dev', + shopify: { + stores: { + production: { themeToken: 'token1' }, + staging: { themeToken: 'token2' } + } + } + } + }), + saveCredentials: vi.fn() + }, + devOps: { + startDev: vi.fn() + } + }; + + vi.clearAllMocks(); + }); + + describe('handleHealthCheck', () => { + test('shows health check menu with options', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + vi.mocked(select).mockResolvedValue(Symbol('cancel')); + vi.mocked(isCancel).mockReturnValue(true); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + await handleHealthCheck(mockContext); + + // Assert + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Health Check:", + options: expect.arrayContaining([ + expect.objectContaining({ value: "single", label: "Check Single Shop" }), + expect.objectContaining({ value: "all", label: "Check All Shops" }) + ]) + }) + ); + }); + }); + + describe('Single Shop Health Check', () => { + test('performs complete health check for a shop', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select) + .mockResolvedValueOnce('single') // Health check type + .mockResolvedValueOnce('shop-a'); // Shop selection + + vi.mocked(isCancel).mockReturnValue(false); + + // Mock git branch existence checks + vi.mocked(execSync).mockImplementation((cmd) => { + const command = typeof cmd === 'string' ? cmd : ''; + if (command.includes('git rev-parse --verify')) return '' as any; + if (command.includes('git rev-list --count')) return '0' as any; + return '' as any; + }); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert + expect(result.success).toBe(true); + expect(mockContext.shopOps.loadConfig).toHaveBeenCalledWith('shop-a'); + expect(mockContext.credOps.loadCredentials).toHaveBeenCalledWith('shop-a'); + }); + + test('detects missing credentials', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select) + .mockResolvedValueOnce('single') + .mockResolvedValueOnce('shop-a'); + + vi.mocked(isCancel).mockReturnValue(false); + + // Mock no credentials + vi.mocked(mockContext.credOps.loadCredentials).mockResolvedValue({ + success: true, + data: null + }); + + vi.mocked(execSync).mockReturnValue('' as any); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert - Should still succeed (informational only) + expect(result.success).toBe(true); + }); + + test('detects missing git branches', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select) + .mockResolvedValueOnce('single') + .mockResolvedValueOnce('shop-a'); + + vi.mocked(isCancel).mockReturnValue(false); + + // Mock git commands to fail (branches don't exist) + vi.mocked(execSync).mockImplementation((cmd) => { + const command = typeof cmd === 'string' ? cmd : ''; + if (command.includes('git rev-parse --verify')) { + throw new Error('Branch not found'); + } + return '' as any; + }); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert - Should still succeed (informational only) + expect(result.success).toBe(true); + }); + + test('shows content protection status', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select) + .mockResolvedValueOnce('single') + .mockResolvedValueOnce('shop-a'); + + vi.mocked(isCancel).mockReturnValue(false); + + // Mock shop with protection enabled + vi.mocked(mockContext.shopOps.loadConfig).mockResolvedValue({ + success: true, + data: { + ...mockShopConfig, + contentProtection: { + enabled: true, + mode: 'strict', + verbosity: 'verbose' + } + } + }); + + vi.mocked(execSync).mockReturnValue('' as any); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert + expect(result.success).toBe(true); + }); + }); + + describe('All Shops Health Check', () => { + test('checks all shops', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('all'); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(execSync).mockReturnValue('' as any); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert - Test behavior, not implementation + expect(result.success).toBe(true); + expect(mockContext.shopOps.listShops).toHaveBeenCalled(); + expect(mockContext.shopOps.loadConfig).toHaveBeenCalled(); + expect(mockContext.credOps.loadCredentials).toHaveBeenCalled(); + }); + + test('shows compact output for multiple shops', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select).mockResolvedValue('all'); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(execSync).mockReturnValue('' as any); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert + expect(result.success).toBe(true); + // Both shops should be checked + expect(mockContext.shopOps.listShops).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + test('handles shop with invalid config gracefully', async () => { + // Arrange + const { select, isCancel } = await import('@clack/prompts'); + const { execSync } = await import('child_process'); + + vi.mocked(select) + .mockResolvedValueOnce('single') + .mockResolvedValueOnce('shop-a'); + + vi.mocked(isCancel).mockReturnValue(false); + + // Mock invalid config + vi.mocked(mockContext.shopOps.loadConfig).mockResolvedValue({ + success: false, + error: 'Config not found' + }); + + vi.mocked(execSync).mockReturnValue('' as any); + + const { handleHealthCheck } = await import('../../lib/core/shop-health-check.js'); + + // Act + const result = await handleHealthCheck(mockContext); + + // Assert - Should still complete (informational) + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/lib/core/shop-health-check.ts b/src/lib/core/shop-health-check.ts new file mode 100644 index 0000000..f337c25 --- /dev/null +++ b/src/lib/core/shop-health-check.ts @@ -0,0 +1,403 @@ +import { select, isCancel, note } from "@clack/prompts"; +import { execSync } from "child_process"; +import type { CLIContext, Result } from "./types.js"; +import { validateShopConfig, validateDomain } from "./validation.js"; +import fs from "fs"; +import path from "path"; + +/** + * Shop health check - Diagnostic tool for verifying shop configuration + */ + +interface HealthCheckResult { + readonly shopId: string; + readonly checks: { + readonly config: CheckStatus; + readonly credentials: CheckStatus; + readonly branches: CheckStatus; + readonly contentProtection: CheckStatus; + }; + readonly warnings: string[]; + readonly errors: string[]; + readonly recommendations: string[]; +} + +interface CheckStatus { + readonly status: 'pass' | 'warn' | 'fail' | 'info'; + readonly message: string; + readonly details?: string[]; +} + +export const handleHealthCheck = async (context: CLIContext): Promise> => { + const healthChoice = await select({ + message: "Health Check:", + options: [ + { value: "single", label: "Check Single Shop", hint: "Detailed check for one shop" }, + { value: "all", label: "Check All Shops", hint: "Quick check for all shops" } + ] + }); + + if (isCancel(healthChoice)) return { success: false, error: "Cancelled" }; + + if (healthChoice === "single") { + return checkSingleShop(context); + } else { + return checkAllShops(context); + } +}; + +const checkSingleShop = async (context: CLIContext): Promise> => { + const shopsResult = await context.shopOps.listShops(); + + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet", "⚠️ Error"); + return { success: false, error: "No shops configured" }; + } + + const shopId = await selectShop(shopsResult.data); + if (!shopId) return { success: false, error: "No shop selected" }; + + const healthResult = await performHealthCheck(context, shopId); + displayDetailedHealth(healthResult); + + return { success: true }; +}; + +const checkAllShops = async (context: CLIContext): Promise> => { + const shopsResult = await context.shopOps.listShops(); + + if (!shopsResult.success || !shopsResult.data?.length) { + note("No shops configured yet", "⚠️ Error"); + return { success: false, error: "No shops configured" }; + } + + note(`Checking health for ${shopsResult.data.length} shops...`, "🏥 Health Check"); + + for (const shopId of shopsResult.data) { + const healthResult = await performHealthCheck(context, shopId); + displayCompactHealth(healthResult); + } + + return { success: true }; +}; + +const performHealthCheck = async (context: CLIContext, shopId: string): Promise => { + const warnings: string[] = []; + const errors: string[] = []; + const recommendations: string[] = []; + + // Check 1: Configuration + const configCheck = await checkConfiguration(context, shopId, errors, warnings); + + // Check 2: Credentials + const credentialsCheck = await checkCredentials(context, shopId, errors, warnings, recommendations); + + // Check 3: Git Branches + const branchesCheck = await checkBranches(shopId, errors, warnings, recommendations); + + // Check 4: Content Protection + const protectionCheck = await checkContentProtection(context, shopId); + + return { + shopId, + checks: { + config: configCheck, + credentials: credentialsCheck, + branches: branchesCheck, + contentProtection: protectionCheck + }, + warnings, + errors, + recommendations + }; +}; + +const checkConfiguration = async ( + context: CLIContext, + shopId: string, + errors: string[], + warnings: string[] +): Promise => { + try { + const configResult = await context.shopOps.loadConfig(shopId); + + if (!configResult.success || !configResult.data) { + errors.push("Config file missing or invalid"); + return { status: 'fail', message: 'Config file missing or invalid' }; + } + + const config = configResult.data; + + // Validate config structure + const validationResult = await validateShopConfig(config, shopId); + if (!validationResult.success) { + errors.push(`Config validation failed: ${validationResult.error}`); + return { status: 'fail', message: `Validation failed: ${validationResult.error}` }; + } + + // Check domains + const prodDomainValid = validateDomain(config.shopify.stores.production.domain); + const stagingDomainValid = validateDomain(config.shopify.stores.staging.domain); + + if (!prodDomainValid.success || !stagingDomainValid.success) { + warnings.push("Invalid domain format"); + return { status: 'warn', message: 'Domain format issues detected' }; + } + + return { + status: 'pass', + message: 'Configuration valid', + details: [ + `Production: ${config.shopify.stores.production.domain}`, + `Staging: ${config.shopify.stores.staging.domain}`, + `Auth: ${config.shopify.authentication.method}` + ] + }; + } catch (error) { + errors.push(`Config check failed: ${error instanceof Error ? error.message : String(error)}`); + return { status: 'fail', message: 'Config check failed' }; + } +}; + +const checkCredentials = async ( + context: CLIContext, + shopId: string, + errors: string[], + warnings: string[], + recommendations: string[] +): Promise => { + try { + const credResult = await context.credOps.loadCredentials(shopId); + + if (!credResult.success || !credResult.data) { + errors.push("No credentials configured"); + recommendations.push(`Create credentials: shops/credentials/${shopId}.credentials.json`); + return { status: 'fail', message: 'Credentials missing' }; + } + + const creds = credResult.data; + const hasProd = Boolean(creds.shopify?.stores?.production?.themeToken); + const hasStaging = Boolean(creds.shopify?.stores?.staging?.themeToken); + + if (!hasProd || !hasStaging) { + warnings.push("Missing tokens"); + if (!hasProd) recommendations.push("Add production token"); + if (!hasStaging) recommendations.push("Add staging token"); + return { status: 'warn', message: 'Some tokens missing' }; + } + + // Check file permissions (Unix/macOS only) + if (process.platform !== 'win32') { + const credPath = path.join(context.deps.credentialsDir, `${shopId}.credentials.json`); + if (fs.existsSync(credPath)) { + const stats = fs.statSync(credPath); + const mode = (stats.mode & parseInt('777', 8)).toString(8); + + if (mode !== '600') { + warnings.push(`Insecure permissions: ${mode}`); + recommendations.push(`Run: chmod 600 shops/credentials/${shopId}.credentials.json`); + return { + status: 'warn', + message: `Permissions too open (${mode})`, + details: ['Production token present', 'Staging token present'] + }; + } + } + } + + return { + status: 'pass', + message: 'Credentials configured', + details: [ + '✅ Production token present', + '✅ Staging token present', + process.platform !== 'win32' ? '✅ File permissions: 600' : 'ℹ️ Windows (permissions N/A)' + ] + }; + } catch (error) { + errors.push(`Credentials check failed: ${error instanceof Error ? error.message : String(error)}`); + return { status: 'fail', message: 'Credentials check failed' }; + } +}; + +const checkBranches = async ( + shopId: string, + errors: string[], + warnings: string[], + recommendations: string[] +): Promise => { + try { + const mainBranch = `${shopId}/main`; + const stagingBranch = `${shopId}/staging`; + + const mainExists = checkBranchExists(mainBranch); + const stagingExists = checkBranchExists(stagingBranch); + + if (!mainExists || !stagingExists) { + if (!mainExists) { + errors.push(`Branch ${mainBranch} not found`); + recommendations.push(`Create and push: git checkout -b ${mainBranch} && git push -u origin ${mainBranch}`); + } + if (!stagingExists) { + errors.push(`Branch ${stagingBranch} not found`); + recommendations.push(`Create and push: git checkout -b ${stagingBranch} && git push -u origin ${stagingBranch}`); + } + return { status: 'fail', message: 'Required branches missing' }; + } + + // Check if branches are in sync + try { + const behind = execSync(`git rev-list --count ${stagingBranch}..${mainBranch} 2>/dev/null || echo "0"`, { + encoding: 'utf8' + }).trim(); + + if (parseInt(behind) > 0) { + warnings.push(`${stagingBranch} is ${behind} commits behind ${mainBranch}`); + recommendations.push(`Consider syncing: Create PR from ${mainBranch} to ${stagingBranch}`); + return { + status: 'warn', + message: `Branches out of sync (${behind} commits behind)`, + details: [`${mainBranch} exists`, `${stagingBranch} exists`] + }; + } + } catch { + // Can't check sync status (branches might not have common ancestor) + } + + return { + status: 'pass', + message: 'Git branches configured', + details: [`${mainBranch} exists`, `${stagingBranch} exists`] + }; + } catch (error) { + errors.push(`Branch check failed: ${error instanceof Error ? error.message : String(error)}`); + return { status: 'fail', message: 'Branch check failed' }; + } +}; + +const checkContentProtection = async (context: CLIContext, shopId: string): Promise => { + try { + const configResult = await context.shopOps.loadConfig(shopId); + + if (!configResult.success || !configResult.data) { + return { status: 'info', message: 'Cannot check (config unavailable)' }; + } + + const protection = configResult.data.contentProtection; + + if (!protection || !protection.enabled) { + return { + status: 'info', + message: 'Disabled', + details: ['💡 Enable in: Tools → Content Protection'] + }; + } + + return { + status: 'pass', + message: `Enabled (${protection.mode} mode, ${protection.verbosity})`, + details: [ + `Mode: ${protection.mode}`, + `Verbosity: ${protection.verbosity}`, + '🛡️ Shop content protected from cross-shop overwrites' + ] + }; + } catch { + return { status: 'info', message: 'Cannot check (error)' }; + } +}; + +// Helper functions +const checkBranchExists = (branchName: string): boolean => { + try { + execSync(`git rev-parse --verify origin/${branchName}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +const selectShop = async (shops: string[]): Promise => { + const shopChoice = await select({ + message: "Select shop to check:", + options: shops.map(shop => ({ value: shop, label: shop })) + }); + + return isCancel(shopChoice) ? null : String(shopChoice); +}; + +const displayDetailedHealth = (result: HealthCheckResult): void => { + console.log('\n'); + note(`Health Check Results: ${result.shopId}`, '🏥 Health Check'); + + console.log('\n📋 Configuration:'); + displayCheck(result.checks.config); + + console.log('\n🔑 Credentials:'); + displayCheck(result.checks.credentials); + + console.log('\n🌿 Git Branches:'); + displayCheck(result.checks.branches); + + console.log('\n🛡️ Content Protection:'); + displayCheck(result.checks.contentProtection); + + // Overall status + const hasErrors = result.errors.length > 0; + const hasWarnings = result.warnings.length > 0; + + console.log('\n📊 Overall Status:'); + if (hasErrors) { + console.log(` ❌ ISSUES FOUND (${result.errors.length} error${result.errors.length === 1 ? '' : 's'})`); + } else if (hasWarnings) { + console.log(` ⚠️ WARNINGS (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`); + } else { + console.log(` ✅ HEALTHY`); + } + + // Recommendations + if (result.recommendations.length > 0) { + console.log('\n💡 Recommendations:'); + result.recommendations.forEach(rec => console.log(` • ${rec}`)); + } + + console.log(); +}; + +const displayCompactHealth = (result: HealthCheckResult): void => { + const hasErrors = result.errors.length > 0; + const hasWarnings = result.warnings.length > 0; + + let status = '✅'; + if (hasErrors) status = '❌'; + else if (hasWarnings) status = '⚠️'; + + const summary = [ + result.checks.config.status !== 'pass' ? 'config' : null, + result.checks.credentials.status !== 'pass' ? 'creds' : null, + result.checks.branches.status !== 'pass' ? 'branches' : null + ].filter(Boolean).join(', '); + + console.log(` ${status} ${result.shopId}${summary ? ` (${summary})` : ''}`); + + if (result.recommendations.length > 0 && result.recommendations.length <= 2) { + result.recommendations.forEach(rec => console.log(` 💡 ${rec}`)); + } +}; + +const displayCheck = (check: CheckStatus): void => { + const icon = { + 'pass': '✅', + 'warn': '⚠️', + 'fail': '❌', + 'info': 'ℹ️' + }[check.status]; + + console.log(` ${icon} ${check.message}`); + + if (check.details) { + check.details.forEach(detail => { + console.log(` ${detail}`); + }); + } +}; diff --git a/src/lib/core/tools.ts b/src/lib/core/tools.ts index 2d3a0b0..7b1f7bf 100644 --- a/src/lib/core/tools.ts +++ b/src/lib/core/tools.ts @@ -4,6 +4,7 @@ import { syncShops } from "./shop-sync.js"; import { linkThemes } from "./theme-linking.js"; import { checkVersions } from "./version-check.js"; import { handleContentProtection } from "./content-protection.js"; +import { handleHealthCheck } from "./shop-health-check.js"; /** * Tools menu coordination @@ -14,6 +15,7 @@ export const handleTools = async (context: CLIContext): Promise> => message: "Select tool:", options: [ { value: "sync", label: "Sync Shops", hint: "Create PRs to deploy main branch changes to shops" }, + { value: "health", label: "Health Check", hint: "Verify shop configuration and setup" }, { value: "protection", label: "Content Protection", hint: "Configure content protection per shop" }, { value: "themes", label: "Link Themes", hint: "Connect Git branches to Shopify themes" }, { value: "versions", label: "Version Check", hint: "Check versions of important packages" } @@ -25,6 +27,8 @@ export const handleTools = async (context: CLIContext): Promise> => switch (toolChoice) { case "sync": return syncShops(context); + case "health": + return handleHealthCheck(context); case "protection": return handleContentProtection(context); case "themes":