From a937a0b9782b4ce8f48955b7f2aa006900bbbc59 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 24 Jan 2026 19:26:58 +0100 Subject: [PATCH] feat: Initialize mycopunk-swag repository Phase 1 implementation: - CLI scaffolding with Typer (design, product, mockup, catalog commands) - Design management with templates (sticker, tshirt, print) - Export automation via Inkscape CLI - POD provider base classes (Printful, Prodigi) - Product catalog configuration (products.yaml) - Design guidelines and workflow documentation - Helper scripts (batch-export, generate-mockups, sync-catalog) - SVG templates for common product sizes Co-Authored-By: Claude Opus 4.5 --- .gitignore | 70 ++++ CLAUDE.md | 123 ++++++ LICENSE | 21 + README.md | 109 ++++++ backlog/config.yml | 25 ++ cli/README.md | 81 ++++ cli/mycopunk/__init__.py | 9 + cli/mycopunk/catalog.py | 199 ++++++++++ cli/mycopunk/cli.py | 213 +++++++++++ cli/mycopunk/config.py | 172 +++++++++ cli/mycopunk/design.py | 383 +++++++++++++++++++ cli/mycopunk/export.py | 268 +++++++++++++ cli/mycopunk/pod/__init__.py | 9 + cli/mycopunk/pod/base.py | 152 ++++++++ cli/mycopunk/pod/printful.py | 325 ++++++++++++++++ cli/mycopunk/pod/prodigi.py | 275 +++++++++++++ cli/pyproject.toml | 90 +++++ cli/tests/__init__.py | 1 + cli/tests/test_design.py | 58 +++ config/.env.example | 39 ++ config/products.yaml | 232 +++++++++++ docs/cli-reference.md | 378 ++++++++++++++++++ docs/design-guidelines.md | 261 +++++++++++++ docs/workflow.md | 321 ++++++++++++++++ scripts/batch-export.sh | 75 ++++ scripts/generate-mockups.sh | 74 ++++ scripts/sync-catalog.sh | 85 ++++ templates/printful/sticker-3x3.svg | 59 +++ templates/printful/tshirt-front-12x16.svg | 41 ++ templates/prodigi/art-print-8x10.svg | 79 ++++ templates/prodigi/vinyl-sticker-kiss-cut.svg | 68 ++++ 31 files changed, 4295 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backlog/config.yml create mode 100644 cli/README.md create mode 100644 cli/mycopunk/__init__.py create mode 100644 cli/mycopunk/catalog.py create mode 100644 cli/mycopunk/cli.py create mode 100644 cli/mycopunk/config.py create mode 100644 cli/mycopunk/design.py create mode 100644 cli/mycopunk/export.py create mode 100644 cli/mycopunk/pod/__init__.py create mode 100644 cli/mycopunk/pod/base.py create mode 100644 cli/mycopunk/pod/printful.py create mode 100644 cli/mycopunk/pod/prodigi.py create mode 100644 cli/pyproject.toml create mode 100644 cli/tests/__init__.py create mode 100644 cli/tests/test_design.py create mode 100644 config/.env.example create mode 100644 config/products.yaml create mode 100644 docs/cli-reference.md create mode 100644 docs/design-guidelines.md create mode 100644 docs/workflow.md create mode 100755 scripts/batch-export.sh create mode 100755 scripts/generate-mockups.sh create mode 100755 scripts/sync-catalog.sh create mode 100644 templates/printful/sticker-3x3.svg create mode 100644 templates/printful/tshirt-front-12x16.svg create mode 100644 templates/prodigi/art-print-8x10.svg create mode 100644 templates/prodigi/vinyl-sticker-kiss-cut.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ba3d9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Environment and secrets +config/.env +.env +*.env.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Exports (regenerate with mycopunk design export) +designs/**/exports/300dpi/*.png +designs/**/exports/300dpi/*.jpg +designs/**/exports/mockups/*.png +designs/**/exports/mockups/*.jpg + +# Keep .gitkeep files +!.gitkeep + +# Cache +.cache/ +*.cache + +# Logs +*.log +logs/ + +# Test coverage +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ + +# Build artifacts +*.tar.gz +*.zip diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..193015a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# Mycopunk Swag - AI Assistant Context + +## Project Overview + +This repository manages mycopunk merchandise (stickers, shirts, patches) with automated print-on-demand fulfillment. The workflow is CLI-driven, using open-source tools (Inkscape, GIMP, ImageMagick) for design work and API integrations with Printful and Prodigi for fulfillment. + +## Key Concepts + +### Design Structure +Each design lives in its own directory under `designs/{category}/{slug}/`: +- `{slug}.svg` - Source vector file +- `metadata.yaml` - Product configuration and metadata +- `exports/` - Generated output files (don't edit manually) + +### POD Providers +- **Printful** - Primary for apparel (t-shirts, hoodies) +- **Prodigi** - Primary for stickers and art prints + +### CLI Tool +Located in `cli/`, installed with `pip install -e .` +Main entry point: `mycopunk` command + +## File Locations + +| What | Where | +|------|-------| +| Design sources | `designs/{stickers,shirts,misc}/` | +| Product templates | `templates/{printful,prodigi}/` | +| CLI source | `cli/mycopunk/` | +| Configuration | `config/` | +| API credentials | `config/.env` (gitignored) | + +## Common Tasks + +### Creating a New Design +```bash +mycopunk design new stickers/design-name --template=sticker-3x3 +# Then edit designs/stickers/design-name/design-name.svg in Inkscape +``` + +### Exporting Designs +```bash +# Export single design +mycopunk design export stickers/design-name + +# Batch export all stickers +mycopunk batch export --type=sticker +``` + +### POD Operations +```bash +# Always test in sandbox first +mycopunk product create stickers/design-name --provider=prodigi --sandbox + +# Push to production +mycopunk product push stickers/design-name --provider=prodigi +``` + +## Design Requirements Quick Reference + +| Product | Dimensions | Pixels @ 300 DPI | +|---------|-----------|------------------| +| T-shirt front | 12" × 16" | 3600 × 4800 | +| Sticker 3×3" | 3" × 3" | 900 × 900 | +| Sticker 6×6" | 6" × 6" | 1800 × 1800 | +| Art print 8×10 | 8" × 10" | 2400 × 3000 | + +## Metadata.yaml Structure + +```yaml +name: "Design Name" +slug: design-name +description: "What this design represents" +tags: [mycelium, punk, nature] +created: 2025-01-24 +author: jeff + +source: + file: design-name.svg + format: svg + dimensions: { width: 4800, height: 4800 } + dpi: 300 + color_profile: sRGB + +products: + - type: sticker + provider: prodigi + sku: STICKER-VINYL-KISS-3X3 + variants: [matte, gloss] + retail_price: 3.50 + +status: active # draft, active, retired +``` + +## API Configuration + +Credentials stored in `config/.env` (never commit): +```bash +PRINTFUL_API_TOKEN=xxx +PRODIGI_API_KEY_SANDBOX=xxx +PRODIGI_API_KEY_LIVE=xxx +``` + +## Development Workflow + +1. Work on `dev` branch for new features +2. Test CLI changes with `pip install -e .` +3. Run `mycopunk design validate` before committing +4. Push to Gitea, auto-mirrors to GitHub +5. Merge to `main` when verified working + +## Important Notes + +- Always validate designs before pushing to POD services +- Use `--sandbox` flag when testing POD integrations +- Exports are gitignored - regenerate with `mycopunk design export` +- Mockups require POD API access (not available offline) + +## Links + +- [Printful API Docs](https://developers.printful.com/docs/) +- [Prodigi API Docs](https://www.prodigi.com/print-api/docs/) +- [Inkscape CLI Reference](https://wiki.inkscape.org/wiki/Using_the_Command_Line) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b997b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jeff Emmett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3ddfb1 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# 🍄 Mycopunk Swag + +**Decentralized merchandise for the mycelial revolution.** + +A CLI-driven workflow for designing, managing, and distributing mycopunk merchandise (stickers, shirts, patches, zines) with automated print-on-demand fulfillment. + +## Philosophy + +Like mycelium networks that share resources without central control, this project embodies: +- **Open source designs** - Fork, remix, distribute +- **Decentralized production** - POD means no inventory, no warehouses +- **Anarchist tech** - Self-hosted, privacy-respecting, no platform lock-in +- **Community-driven** - Contribute designs, suggest products, share the spores + +## Quick Start + +```bash +# Install CLI +cd cli && pip install -e . + +# Create a new design +mycopunk design new stickers/my-design --template=sticker-3x3 + +# Export to print-ready files +mycopunk design export stickers/my-design + +# Validate against POD requirements +mycopunk design validate stickers/my-design + +# Create product on POD service +mycopunk product create stickers/my-design --provider=prodigi +``` + +## Project Structure + +``` +mycopunk-swag/ +├── designs/ # Source design files (SVG, XCF) +│ ├── stickers/ # Sticker designs +│ ├── shirts/ # Apparel designs +│ └── misc/ # Patches, pins, zines +├── templates/ # POD service templates +├── cli/ # Python CLI tool +├── config/ # Product & pricing configuration +├── scripts/ # Helper scripts +└── docs/ # Documentation +``` + +## Design Workflow + +1. **Create** - Design in Inkscape/GIMP, save as SVG +2. **Configure** - Add `metadata.yaml` with product mappings +3. **Export** - Generate print-ready PNGs at required DPIs +4. **Validate** - Check against POD service requirements +5. **Push** - Upload to Printful/Prodigi +6. **Mockup** - Generate product mockups for shop + +## Print-on-Demand Providers + +| Provider | Products | Best For | +|----------|----------|----------| +| **Printful** | Apparel, accessories | T-shirts, hoodies, quality | +| **Prodigi** | Stickers, prints | Vinyl stickers, art prints, global shipping | + +## CLI Reference + +See [docs/cli-reference.md](docs/cli-reference.md) for full command documentation. + +### Core Commands + +```bash +mycopunk design new # Create new design scaffold +mycopunk design list # List all designs +mycopunk design export # Export to print formats +mycopunk design validate # Validate requirements + +mycopunk product create # Create POD product +mycopunk product push # Push design to POD +mycopunk product list # List all products + +mycopunk mockup generate # Generate mockups +mycopunk catalog sync # Sync POD catalogs +``` + +## Contributing Designs + +1. Fork the repo +2. Create your design in `designs/` following the structure +3. Add `metadata.yaml` with required fields +4. Run `mycopunk design validate` to check requirements +5. Submit a PR with mockups in the description + +### Design Requirements + +- **Format**: SVG (vectors) or high-res PNG (4800×4800 minimum) +- **Resolution**: 300 DPI for all exports +- **Color Profile**: sRGB +- **Transparency**: PNG with transparent background preferred + +See [docs/design-guidelines.md](docs/design-guidelines.md) for detailed specs. + +## License + +Designs are released under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) unless otherwise noted. +Code is released under [MIT License](LICENSE). + +--- + +*Spread the spores. Decentralize everything.* 🍄 diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..f54c96f --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,25 @@ +project_name: "Mycopunk Swag" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [cli, pod-integration, design, documentation, infrastructure] +milestones: + - name: "Phase 1: Foundation" + description: "Repository setup, CLI scaffolding, documentation" + - name: "Phase 2: CLI Core" + description: "Design management, export automation" + - name: "Phase 3: POD Integration" + description: "Printful and Prodigi API wrappers" + - name: "Phase 4: Automation" + description: "Batch operations, CI/CD, monitoring" + - name: "Phase 5: Custom Shop" + description: "Headless commerce, storefront, payments" +date_format: yyyy-mm-dd +max_column_width: 20 +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +zero_padded_ids: 3 +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 60 diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..6ae99ec --- /dev/null +++ b/cli/README.md @@ -0,0 +1,81 @@ +# Mycopunk CLI + +Command-line tool for managing mycopunk merchandise and print-on-demand fulfillment. + +## Installation + +```bash +# Install in development mode +pip install -e . + +# Or install with dev dependencies +pip install -e ".[dev]" +``` + +## Quick Start + +```bash +# Create a new sticker design +mycopunk design new stickers/my-design --template=sticker-3x3 + +# Export to print-ready PNG +mycopunk design export stickers/my-design + +# Validate against POD requirements +mycopunk design validate stickers/my-design + +# Create product on Prodigi (sandbox mode) +mycopunk product create stickers/my-design --provider=prodigi --sandbox +``` + +## Commands + +See the full [CLI Reference](../docs/cli-reference.md) for all commands. + +### Design Management +- `mycopunk design new` - Create design scaffold +- `mycopunk design list` - List all designs +- `mycopunk design export` - Export to print formats +- `mycopunk design validate` - Validate requirements + +### POD Integration +- `mycopunk product create` - Create product on POD +- `mycopunk product push` - Push design updates +- `mycopunk product list` - List products +- `mycopunk mockup generate` - Generate mockups + +### Batch Operations +- `mycopunk batch export` - Export all designs +- `mycopunk batch push` - Push all to POD + +## Configuration + +Copy `../config/.env.example` to `../config/.env` and add your API keys: + +```bash +PRINTFUL_API_TOKEN=your_token +PRODIGI_API_KEY_SANDBOX=your_sandbox_key +PRODIGI_API_KEY_LIVE=your_live_key +``` + +## Development + +```bash +# Run tests +pytest + +# Format code +black mycopunk/ + +# Lint +ruff check mycopunk/ + +# Type check +mypy mycopunk/ +``` + +## Requirements + +- Python 3.10+ +- Inkscape (for SVG export) +- ImageMagick (optional, for local mockups) diff --git a/cli/mycopunk/__init__.py b/cli/mycopunk/__init__.py new file mode 100644 index 0000000..1f6ba12 --- /dev/null +++ b/cli/mycopunk/__init__.py @@ -0,0 +1,9 @@ +""" +Mycopunk CLI - Merchandise management and POD fulfillment tool. + +A CLI-driven workflow for designing, managing, and distributing +mycopunk merchandise with automated print-on-demand fulfillment. +""" + +__version__ = "0.1.0" +__author__ = "Jeff Emmett" diff --git a/cli/mycopunk/catalog.py b/cli/mycopunk/catalog.py new file mode 100644 index 0000000..923d632 --- /dev/null +++ b/cli/mycopunk/catalog.py @@ -0,0 +1,199 @@ +""" +Catalog and product management for POD services. +""" + +from pathlib import Path +from typing import Optional + +import yaml +from rich.console import Console +from rich.table import Table + +console = Console() + + +def get_project_root() -> Path: + """Find the project root.""" + current = Path.cwd() + while current != current.parent: + if (current / "designs").is_dir(): + return current + current = current.parent + return Path.cwd() + + +DESIGNS_DIR = get_project_root() / "designs" +CONFIG_DIR = get_project_root() / "config" + + +def create_product(path: str, provider: str, sandbox: bool = False) -> None: + """Create a product on a POD service.""" + design_dir = DESIGNS_DIR / path + + if not design_dir.exists(): + console.print(f"[red]Error: Design not found at {design_dir}[/red]") + raise SystemExit(1) + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + console.print("[red]Error: metadata.yaml not found[/red]") + raise SystemExit(1) + + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + + products = metadata.get("products", []) + matching_products = [p for p in products if p.get("provider") == provider] + + if not matching_products: + console.print(f"[yellow]No products configured for provider '{provider}'[/yellow]") + console.print("Add products to metadata.yaml first.") + raise SystemExit(1) + + mode = "SANDBOX" if sandbox else "PRODUCTION" + console.print(f"\n[bold]Creating products on {provider} ({mode})[/bold]\n") + + for product in matching_products: + product_type = product.get("type", "unknown") + sku = product.get("sku", "N/A") + price = product.get("retail_price", 0) + + console.print(f" Product: {product_type}") + console.print(f" SKU: {sku}") + console.print(f" Price: ${price:.2f}") + + if provider == "printful": + _create_printful_product(design_dir, metadata, product, sandbox) + elif provider == "prodigi": + _create_prodigi_product(design_dir, metadata, product, sandbox) + else: + console.print(f"[red] Unknown provider: {provider}[/red]") + + console.print() + + +def _create_printful_product( + design_dir: Path, + metadata: dict, + product: dict, + sandbox: bool +) -> None: + """Create product on Printful.""" + console.print("[yellow] → Printful API integration not yet implemented[/yellow]") + console.print("[dim] Would create product with Printful API[/dim]") + # TODO: Implement Printful API call + # 1. Upload design file to Printful + # 2. Create sync product + # 3. Add sync variants + # 4. Store Printful product ID in metadata + + +def _create_prodigi_product( + design_dir: Path, + metadata: dict, + product: dict, + sandbox: bool +) -> None: + """Create product on Prodigi.""" + console.print("[yellow] → Prodigi API integration not yet implemented[/yellow]") + console.print("[dim] Would create product with Prodigi API[/dim]") + # TODO: Implement Prodigi API call + # 1. Upload design file to Prodigi + # 2. Create product template + # 3. Store Prodigi template ID in metadata + + +def push_product(path: str, provider: Optional[str] = None) -> None: + """Push design updates to POD product.""" + design_dir = DESIGNS_DIR / path + + if not design_dir.exists(): + console.print(f"[red]Error: Design not found at {design_dir}[/red]") + raise SystemExit(1) + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + console.print("[red]Error: metadata.yaml not found[/red]") + raise SystemExit(1) + + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + + products = metadata.get("products", []) + + if provider: + products = [p for p in products if p.get("provider") == provider] + + if not products: + console.print("[yellow]No products to push[/yellow]") + raise SystemExit(1) + + console.print(f"\n[bold]Pushing updates for: {metadata.get('name', path)}[/bold]\n") + + for product in products: + prod_provider = product.get("provider", "unknown") + product_type = product.get("type", "unknown") + + console.print(f" Pushing {product_type} to {prod_provider}...") + console.print("[yellow] → API integration not yet implemented[/yellow]") + # TODO: Implement push logic + # 1. Check if product exists on provider + # 2. Upload updated design file + # 3. Sync any metadata changes + + +def list_products(provider: Optional[str] = None) -> None: + """List all products across designs.""" + table = Table(title="🍄 Mycopunk Products") + table.add_column("Design", style="cyan") + table.add_column("Type", style="green") + table.add_column("Provider", style="yellow") + table.add_column("SKU", style="blue") + table.add_column("Price", style="magenta", justify="right") + table.add_column("Status", style="dim") + + products_found = 0 + + for category_dir in DESIGNS_DIR.iterdir(): + if not category_dir.is_dir(): + continue + + for design_dir in category_dir.iterdir(): + if not design_dir.is_dir(): + continue + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + continue + + try: + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + except Exception: + continue + + design_path = f"{category_dir.name}/{design_dir.name}" + design_status = metadata.get("status", "unknown") + products = metadata.get("products", []) + + for product in products: + prod_provider = product.get("provider", "unknown") + + if provider and prod_provider != provider: + continue + + products_found += 1 + table.add_row( + design_path, + product.get("type", "unknown"), + prod_provider, + product.get("sku", "N/A"), + f"${product.get('retail_price', 0):.2f}", + design_status, + ) + + if products_found == 0: + console.print("[yellow]No products found.[/yellow]") + else: + console.print(table) + console.print(f"\n[dim]Total: {products_found} product(s)[/dim]") diff --git a/cli/mycopunk/cli.py b/cli/mycopunk/cli.py new file mode 100644 index 0000000..8e39ee2 --- /dev/null +++ b/cli/mycopunk/cli.py @@ -0,0 +1,213 @@ +""" +Main CLI entry point for mycopunk. +""" + +import typer +from rich.console import Console +from rich.table import Table +from typing import Optional +from pathlib import Path + +from . import __version__ + +app = typer.Typer( + name="mycopunk", + help="🍄 CLI tool for managing mycopunk merchandise and POD fulfillment", + add_completion=True, +) + +console = Console() + +# Sub-command groups +design_app = typer.Typer(help="Design management commands") +product_app = typer.Typer(help="POD product commands") +mockup_app = typer.Typer(help="Mockup generation commands") +catalog_app = typer.Typer(help="Catalog management commands") +batch_app = typer.Typer(help="Batch operations") + +app.add_typer(design_app, name="design") +app.add_typer(product_app, name="product") +app.add_typer(mockup_app, name="mockup") +app.add_typer(catalog_app, name="catalog") +app.add_typer(batch_app, name="batch") + + +def version_callback(value: bool) -> None: + if value: + console.print(f"mycopunk version {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, "--version", "-v", callback=version_callback, is_eager=True, + help="Show version and exit" + ), +) -> None: + """ + 🍄 Mycopunk CLI - Merchandise management and POD fulfillment. + + Manage designs, generate exports, and push products to print-on-demand + services like Printful and Prodigi. + """ + pass + + +# ============================================ +# Design Commands +# ============================================ + +@design_app.command("new") +def design_new( + path: str = typer.Argument(..., help="Design path (e.g., stickers/my-design)"), + template: str = typer.Option( + "sticker-3x3", "--template", "-t", + help="Template to use (sticker-3x3, tshirt-front, etc.)" + ), +) -> None: + """Create a new design scaffold with metadata template.""" + from .design import create_design + create_design(path, template) + + +@design_app.command("list") +def design_list( + status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status"), + design_type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"), +) -> None: + """List all designs in the repository.""" + from .design import list_designs + list_designs(status, design_type) + + +@design_app.command("export") +def design_export( + path: str = typer.Argument(..., help="Design path to export"), + dpi: int = typer.Option(300, "--dpi", help="Export resolution"), + format: str = typer.Option("png", "--format", "-f", help="Export format (png, jpg)"), +) -> None: + """Export design to print-ready files.""" + from .export import export_design + export_design(path, dpi, format) + + +@design_app.command("validate") +def design_validate( + path: str = typer.Argument(..., help="Design path to validate"), + strict: bool = typer.Option(False, "--strict", help="Fail on warnings"), +) -> None: + """Validate design meets POD requirements.""" + from .design import validate_design + validate_design(path, strict) + + +# ============================================ +# Product Commands +# ============================================ + +@product_app.command("create") +def product_create( + path: str = typer.Argument(..., help="Design path"), + provider: str = typer.Option("prodigi", "--provider", "-p", help="POD provider"), + sandbox: bool = typer.Option(False, "--sandbox", help="Use sandbox/test mode"), +) -> None: + """Create product on POD service from design.""" + from .catalog import create_product + create_product(path, provider, sandbox) + + +@product_app.command("push") +def product_push( + path: str = typer.Argument(..., help="Design path"), + provider: Optional[str] = typer.Option(None, "--provider", "-p", help="POD provider"), +) -> None: + """Push design updates to POD product.""" + from .catalog import push_product + push_product(path, provider) + + +@product_app.command("list") +def product_list( + provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Filter by provider"), +) -> None: + """List all products by provider.""" + from .catalog import list_products + list_products(provider) + + +# ============================================ +# Mockup Commands +# ============================================ + +@mockup_app.command("generate") +def mockup_generate( + path: str = typer.Argument(..., help="Design path"), + all_variants: bool = typer.Option(False, "--all", help="Generate all variants"), + color: Optional[str] = typer.Option(None, "--color", "-c", help="Product color"), + size: Optional[str] = typer.Option(None, "--size", "-s", help="Product size"), +) -> None: + """Generate product mockups for a design.""" + console.print(f"[yellow]Generating mockups for {path}...[/yellow]") + console.print("[dim]Mockup generation requires POD API access.[/dim]") + # TODO: Implement mockup generation + console.print("[red]Not yet implemented[/red]") + + +# ============================================ +# Catalog Commands +# ============================================ + +@catalog_app.command("sync") +def catalog_sync( + provider: str = typer.Option("all", "--provider", "-p", help="Provider to sync"), +) -> None: + """Sync product catalog from POD services.""" + console.print(f"[yellow]Syncing catalog from {provider}...[/yellow]") + # TODO: Implement catalog sync + console.print("[red]Not yet implemented[/red]") + + +# ============================================ +# Batch Commands +# ============================================ + +@batch_app.command("export") +def batch_export( + design_type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"), + status: str = typer.Option("active", "--status", "-s", help="Filter by status"), +) -> None: + """Export all designs matching criteria.""" + console.print(f"[yellow]Batch exporting designs (type={design_type}, status={status})...[/yellow]") + # TODO: Implement batch export + console.print("[red]Not yet implemented[/red]") + + +@batch_app.command("push") +def batch_push( + status: str = typer.Option("active", "--status", "-s", help="Filter by status"), + provider: Optional[str] = typer.Option(None, "--provider", "-p", help="POD provider"), +) -> None: + """Push all designs to POD services.""" + console.print(f"[yellow]Batch pushing designs (status={status})...[/yellow]") + # TODO: Implement batch push + console.print("[red]Not yet implemented[/red]") + + +# ============================================ +# Report Commands +# ============================================ + +@app.command("report") +def report( + report_type: str = typer.Argument("pricing", help="Report type (pricing, inventory, orders)"), + output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"), +) -> None: + """Generate reports (pricing, inventory, orders).""" + console.print(f"[yellow]Generating {report_type} report...[/yellow]") + # TODO: Implement reports + console.print("[red]Not yet implemented[/red]") + + +if __name__ == "__main__": + app() diff --git a/cli/mycopunk/config.py b/cli/mycopunk/config.py new file mode 100644 index 0000000..4074f87 --- /dev/null +++ b/cli/mycopunk/config.py @@ -0,0 +1,172 @@ +""" +Configuration management for mycopunk CLI. +""" + +import os +from pathlib import Path +from typing import Optional, Any + +import yaml +from dotenv import load_dotenv + + +def get_project_root() -> Path: + """Find the project root (directory containing designs/).""" + current = Path.cwd() + while current != current.parent: + if (current / "designs").is_dir(): + return current + current = current.parent + return Path.cwd() + + +PROJECT_ROOT = get_project_root() +CONFIG_DIR = PROJECT_ROOT / "config" + + +class Config: + """Configuration manager for mycopunk.""" + + _instance: Optional["Config"] = None + + def __new__(cls) -> "Config": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self) -> None: + if self._initialized: + return + + self._initialized = True + self._products: dict = {} + self._pricing: dict = {} + + # Load environment variables + env_path = CONFIG_DIR / ".env" + if env_path.exists(): + load_dotenv(env_path) + + # Load product configuration + products_path = CONFIG_DIR / "products.yaml" + if products_path.exists(): + with open(products_path) as f: + self._products = yaml.safe_load(f) or {} + + # Load pricing configuration + pricing_path = CONFIG_DIR / "pricing.yaml" + if pricing_path.exists(): + with open(pricing_path) as f: + self._pricing = yaml.safe_load(f) or {} + + @property + def env(self) -> str: + """Get current environment (development/production).""" + return os.getenv("MYCOPUNK_ENV", "development") + + @property + def is_production(self) -> bool: + """Check if running in production mode.""" + return self.env == "production" + + @property + def debug(self) -> bool: + """Check if debug mode is enabled.""" + return os.getenv("DEBUG", "false").lower() == "true" + + @property + def default_provider(self) -> str: + """Get default POD provider.""" + return os.getenv("DEFAULT_PROVIDER", "prodigi") + + # API Keys + + @property + def printful_token(self) -> Optional[str]: + """Get Printful API token.""" + return os.getenv("PRINTFUL_API_TOKEN") + + @property + def prodigi_key(self) -> Optional[str]: + """Get Prodigi API key (sandbox or live based on env).""" + if self.is_production: + return os.getenv("PRODIGI_API_KEY_LIVE") + return os.getenv("PRODIGI_API_KEY_SANDBOX") + + @property + def stripe_key(self) -> Optional[str]: + """Get Stripe secret key.""" + return os.getenv("STRIPE_SECRET_KEY") + + # Product Configuration + + def get_product_config(self, product_type: str, size: str = "small") -> dict: + """Get product configuration by type and size.""" + type_config = self._products.get(product_type, {}) + return type_config.get(size, {}) + + def get_provider_sku( + self, + product_type: str, + size: str, + provider: str + ) -> Optional[str]: + """Get provider-specific SKU for a product.""" + config = self.get_product_config(product_type, size) + providers = config.get("providers", {}) + provider_config = providers.get(provider, {}) + return provider_config.get("sku") + + def get_base_cost( + self, + product_type: str, + size: str, + provider: str, + variant: Optional[str] = None + ) -> float: + """Get base cost for a product from provider.""" + config = self.get_product_config(product_type, size) + providers = config.get("providers", {}) + provider_config = providers.get(provider, {}) + + base_cost = provider_config.get("base_cost", 0) + + # Handle size-specific pricing (apparel) + if isinstance(base_cost, dict) and variant: + return base_cost.get(variant, 0) + + return float(base_cost) if not isinstance(base_cost, dict) else 0 + + # Pricing + + def calculate_retail_price( + self, + base_cost: float, + product_type: str + ) -> float: + """Calculate retail price from base cost with markup.""" + pricing = self._pricing or self._products.get("pricing", {}) + rules = pricing.get("rules", {}) + type_rules = rules.get(product_type, {}) + + markup = type_rules.get("markup", pricing.get("default_markup", 2.0)) + minimum = type_rules.get("minimum_price", 0) + + calculated = base_cost * markup + price = max(calculated, minimum) + + # Apply rounding + rounding = pricing.get("rounding", "nearest_99") + if rounding == "nearest_99": + price = round(price) - 0.01 + elif rounding == "nearest_50": + price = round(price * 2) / 2 + + return max(price, minimum) + + +# Singleton accessor +def get_config() -> Config: + """Get the configuration singleton.""" + return Config() diff --git a/cli/mycopunk/design.py b/cli/mycopunk/design.py new file mode 100644 index 0000000..a285d82 --- /dev/null +++ b/cli/mycopunk/design.py @@ -0,0 +1,383 @@ +""" +Design management commands for mycopunk CLI. +""" + +import os +from pathlib import Path +from typing import Optional +from datetime import date + +import yaml +from rich.console import Console +from rich.table import Table +from rich.panel import Panel + +console = Console() + +# Project root detection +def get_project_root() -> Path: + """Find the project root (directory containing designs/).""" + current = Path.cwd() + while current != current.parent: + if (current / "designs").is_dir(): + return current + current = current.parent + # Default to cwd if not found + return Path.cwd() + + +DESIGNS_DIR = get_project_root() / "designs" +TEMPLATES_DIR = get_project_root() / "templates" + + +# Default metadata template +METADATA_TEMPLATE = """name: "{name}" +slug: {slug} +description: "Description of your design" +tags: [mycopunk, mycelium] +created: {date} +author: jeff + +source: + file: {slug}.svg + format: svg + dimensions: + width: {width} + height: {height} + dpi: 300 + color_profile: sRGB + +products: +{products} + +status: draft +""" + +# Product templates by type +PRODUCT_TEMPLATES = { + "sticker-3x3": { + "width": 900, + "height": 900, + "products": """ - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-3X3 + variants: [matte, gloss] + retail_price: 3.50""", + }, + "sticker-4x4": { + "width": 1200, + "height": 1200, + "products": """ - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-4X4 + variants: [matte, gloss] + retail_price: 4.50""", + }, + "sticker-6x6": { + "width": 1800, + "height": 1800, + "products": """ - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-6X6 + variants: [matte] + retail_price: 6.00""", + }, + "tshirt-front": { + "width": 3600, + "height": 4800, + "products": """ - type: tshirt + provider: printful + sku: 71 + placement: front + colors: [black, white, heather_charcoal] + sizes: [S, M, L, XL, 2XL] + retail_price: 28.00""", + }, + "tshirt-chest": { + "width": 1200, + "height": 1200, + "products": """ - type: tshirt + provider: printful + sku: 71 + placement: chest + colors: [black, white] + sizes: [S, M, L, XL, 2XL] + retail_price: 25.00""", + }, + "print-8x10": { + "width": 2400, + "height": 3000, + "products": """ - type: print + provider: prodigi + sku: GLOBAL-FAP-8X10 + variants: [matte, lustre] + retail_price: 15.00""", + }, +} + + +def create_design(path: str, template: str) -> None: + """Create a new design scaffold.""" + # Parse path + parts = path.split("/") + if len(parts) < 2: + console.print("[red]Error: Path must be category/design-name (e.g., stickers/my-design)[/red]") + raise SystemExit(1) + + category = parts[0] + slug = parts[-1] + name = slug.replace("-", " ").title() + + # Validate template + if template not in PRODUCT_TEMPLATES: + console.print(f"[red]Error: Unknown template '{template}'[/red]") + console.print(f"Available templates: {', '.join(PRODUCT_TEMPLATES.keys())}") + raise SystemExit(1) + + template_config = PRODUCT_TEMPLATES[template] + + # Create directory structure + design_dir = DESIGNS_DIR / category / slug + exports_dir = design_dir / "exports" / "300dpi" + mockups_dir = design_dir / "exports" / "mockups" + + if design_dir.exists(): + console.print(f"[red]Error: Design already exists at {design_dir}[/red]") + raise SystemExit(1) + + design_dir.mkdir(parents=True, exist_ok=True) + exports_dir.mkdir(parents=True, exist_ok=True) + mockups_dir.mkdir(parents=True, exist_ok=True) + + # Create metadata.yaml + metadata_content = METADATA_TEMPLATE.format( + name=name, + slug=slug, + date=date.today().isoformat(), + width=template_config["width"], + height=template_config["height"], + products=template_config["products"], + ) + + metadata_path = design_dir / "metadata.yaml" + metadata_path.write_text(metadata_content) + + # Create placeholder SVG + svg_path = design_dir / f"{slug}.svg" + svg_content = f''' + + + + + {name} + +''' + + svg_path.write_text(svg_content) + + # Create .gitkeep in exports + (exports_dir / ".gitkeep").touch() + (mockups_dir / ".gitkeep").touch() + + console.print(Panel( + f"""[green]✓ Created design scaffold[/green] + +[bold]Location:[/bold] {design_dir} +[bold]Template:[/bold] {template} +[bold]Canvas:[/bold] {template_config["width"]}×{template_config["height"]} px + +[bold]Files created:[/bold] + • {slug}.svg - Edit this in Inkscape + • metadata.yaml - Configure products + • exports/ - Generated files go here + +[bold]Next steps:[/bold] + 1. Open {svg_path} in Inkscape + 2. Create your design + 3. Edit metadata.yaml to configure products + 4. Run: mycopunk design export {path}""", + title="🍄 New Design", + )) + + +def list_designs(status: Optional[str] = None, design_type: Optional[str] = None) -> None: + """List all designs in the repository.""" + table = Table(title="🍄 Mycopunk Designs") + table.add_column("Path", style="cyan") + table.add_column("Name", style="green") + table.add_column("Status", style="yellow") + table.add_column("Products", style="blue") + table.add_column("Created", style="dim") + + designs_found = 0 + + for category_dir in DESIGNS_DIR.iterdir(): + if not category_dir.is_dir(): + continue + + # Filter by type + if design_type and category_dir.name != design_type: + continue + + for design_dir in category_dir.iterdir(): + if not design_dir.is_dir(): + continue + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + continue + + try: + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + except Exception: + continue + + # Filter by status + design_status = metadata.get("status", "unknown") + if status and design_status != status: + continue + + # Count products + products = metadata.get("products", []) + product_count = len(products) + + designs_found += 1 + table.add_row( + f"{category_dir.name}/{design_dir.name}", + metadata.get("name", design_dir.name), + design_status, + str(product_count), + metadata.get("created", "unknown"), + ) + + if designs_found == 0: + console.print("[yellow]No designs found.[/yellow]") + console.print("Create a new design with: mycopunk design new stickers/my-design") + else: + console.print(table) + console.print(f"\n[dim]Total: {designs_found} designs[/dim]") + + +def validate_design(path: str, strict: bool = False) -> None: + """Validate a design meets POD requirements.""" + from PIL import Image + + design_dir = DESIGNS_DIR / path + + if not design_dir.exists(): + console.print(f"[red]Error: Design not found at {design_dir}[/red]") + raise SystemExit(1) + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + console.print("[red]Error: metadata.yaml not found[/red]") + raise SystemExit(1) + + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + + console.print(f"\n[bold]Validating: {metadata.get('name', path)}[/bold]\n") + + errors = [] + warnings = [] + passed = [] + + # Check required metadata fields + required_fields = ["name", "slug", "source", "products", "status"] + for field in required_fields: + if field not in metadata: + errors.append(f"Missing required field: {field}") + else: + passed.append(f"Metadata field '{field}' present") + + # Check source file exists + source_info = metadata.get("source", {}) + source_file = source_info.get("file") + if source_file: + source_path = design_dir / source_file + if source_path.exists(): + passed.append(f"Source file exists: {source_file}") + + # If PNG exists, check dimensions + export_png = design_dir / "exports" / "300dpi" / source_file.replace(".svg", ".png") + if export_png.exists(): + try: + with Image.open(export_png) as img: + width, height = img.size + expected_w = source_info.get("dimensions", {}).get("width") + expected_h = source_info.get("dimensions", {}).get("height") + + if expected_w and expected_h: + if width == expected_w and height == expected_h: + passed.append(f"Dimensions correct: {width}×{height}") + else: + errors.append(f"Dimensions mismatch: got {width}×{height}, expected {expected_w}×{expected_h}") + + # Check DPI (approximate from metadata) + if img.info.get("dpi"): + dpi = img.info["dpi"][0] + if dpi >= 300: + passed.append(f"Resolution: {dpi} DPI") + else: + warnings.append(f"Low resolution: {dpi} DPI (recommended: 300+)") + + # Check for transparency + if img.mode == "RGBA": + passed.append("Transparency: Supported") + else: + warnings.append(f"No transparency: Image mode is {img.mode}") + except Exception as e: + warnings.append(f"Could not analyze PNG: {e}") + else: + warnings.append("No exported PNG found - run 'mycopunk design export' first") + else: + errors.append(f"Source file missing: {source_file}") + else: + errors.append("No source file specified in metadata") + + # Check products configuration + products = metadata.get("products", []) + if not products: + warnings.append("No products configured") + else: + for i, product in enumerate(products): + if "type" not in product: + errors.append(f"Product {i+1}: Missing 'type' field") + if "provider" not in product: + errors.append(f"Product {i+1}: Missing 'provider' field") + if "retail_price" not in product: + warnings.append(f"Product {i+1}: No retail price set") + passed.append(f"Products configured: {len(products)}") + + # Print results + for msg in passed: + console.print(f" [green]✓[/green] {msg}") + + for msg in warnings: + console.print(f" [yellow]⚠[/yellow] {msg}") + + for msg in errors: + console.print(f" [red]✗[/red] {msg}") + + console.print() + + if errors: + console.print(f"[red]Validation failed with {len(errors)} error(s)[/red]") + raise SystemExit(1) + elif warnings and strict: + console.print(f"[yellow]Validation failed with {len(warnings)} warning(s) (strict mode)[/yellow]") + raise SystemExit(1) + elif warnings: + console.print(f"[yellow]Validation passed with {len(warnings)} warning(s)[/yellow]") + else: + console.print("[green]✓ Validation passed[/green]") diff --git a/cli/mycopunk/export.py b/cli/mycopunk/export.py new file mode 100644 index 0000000..4f083f8 --- /dev/null +++ b/cli/mycopunk/export.py @@ -0,0 +1,268 @@ +""" +Export automation for mycopunk designs. +Uses Inkscape CLI for SVG to PNG conversion. +""" + +import subprocess +import shutil +from pathlib import Path +from typing import Optional + +import yaml +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn + +console = Console() + + +def get_project_root() -> Path: + """Find the project root (directory containing designs/).""" + current = Path.cwd() + while current != current.parent: + if (current / "designs").is_dir(): + return current + current = current.parent + return Path.cwd() + + +DESIGNS_DIR = get_project_root() / "designs" + + +def check_inkscape() -> bool: + """Check if Inkscape is available.""" + return shutil.which("inkscape") is not None + + +def export_design(path: str, dpi: int = 300, format: str = "png") -> None: + """Export a design to print-ready files.""" + design_dir = DESIGNS_DIR / path + + if not design_dir.exists(): + console.print(f"[red]Error: Design not found at {design_dir}[/red]") + raise SystemExit(1) + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + console.print("[red]Error: metadata.yaml not found[/red]") + raise SystemExit(1) + + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + + source_info = metadata.get("source", {}) + source_file = source_info.get("file") + + if not source_file: + console.print("[red]Error: No source file specified in metadata[/red]") + raise SystemExit(1) + + source_path = design_dir / source_file + if not source_path.exists(): + console.print(f"[red]Error: Source file not found: {source_path}[/red]") + raise SystemExit(1) + + # Determine export method based on file type + if source_file.endswith(".svg"): + export_svg(source_path, design_dir, metadata, dpi, format) + elif source_file.endswith((".png", ".jpg", ".jpeg")): + export_raster(source_path, design_dir, metadata, dpi, format) + else: + console.print(f"[red]Error: Unsupported source format: {source_file}[/red]") + raise SystemExit(1) + + +def export_svg( + source_path: Path, + design_dir: Path, + metadata: dict, + dpi: int, + format: str +) -> None: + """Export SVG using Inkscape.""" + if not check_inkscape(): + console.print("[red]Error: Inkscape not found. Please install Inkscape.[/red]") + console.print("[dim]Ubuntu: sudo apt install inkscape[/dim]") + raise SystemExit(1) + + source_info = metadata.get("source", {}) + slug = source_path.stem + + # Create export directory + export_dir = design_dir / "exports" / f"{dpi}dpi" + export_dir.mkdir(parents=True, exist_ok=True) + + output_file = export_dir / f"{slug}.{format}" + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task(f"Exporting {slug} at {dpi} DPI...", total=None) + + # Build Inkscape command + cmd = [ + "inkscape", + str(source_path), + f"--export-type={format}", + f"--export-dpi={dpi}", + f"--export-filename={output_file}", + ] + + # Add transparent background for PNG + if format == "png": + cmd.append("--export-background-opacity=0") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, # 2 minute timeout + ) + + if result.returncode != 0: + progress.stop() + console.print(f"[red]Inkscape error:[/red]\n{result.stderr}") + raise SystemExit(1) + + progress.update(task, description="Export complete!") + + except subprocess.TimeoutExpired: + progress.stop() + console.print("[red]Error: Export timed out[/red]") + raise SystemExit(1) + except FileNotFoundError: + progress.stop() + console.print("[red]Error: Inkscape not found[/red]") + raise SystemExit(1) + + # Verify output + if output_file.exists(): + size_kb = output_file.stat().st_size / 1024 + console.print(f"\n[green]✓ Exported:[/green] {output_file}") + console.print(f"[dim] Size: {size_kb:.1f} KB[/dim]") + + # Try to get dimensions using PIL + try: + from PIL import Image + with Image.open(output_file) as img: + width, height = img.size + console.print(f"[dim] Dimensions: {width}×{height} px[/dim]") + except Exception: + pass + else: + console.print("[red]Error: Export failed - output file not created[/red]") + raise SystemExit(1) + + +def export_raster( + source_path: Path, + design_dir: Path, + metadata: dict, + dpi: int, + format: str +) -> None: + """Export/convert raster images using PIL.""" + from PIL import Image + + source_info = metadata.get("source", {}) + slug = source_path.stem + + # Create export directory + export_dir = design_dir / "exports" / f"{dpi}dpi" + export_dir.mkdir(parents=True, exist_ok=True) + + output_file = export_dir / f"{slug}.{format}" + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task(f"Processing {slug}...", total=None) + + try: + with Image.open(source_path) as img: + # Convert to appropriate mode + if format == "png" and img.mode != "RGBA": + img = img.convert("RGBA") + elif format in ("jpg", "jpeg") and img.mode == "RGBA": + # Flatten transparency for JPEG + background = Image.new("RGB", img.size, (255, 255, 255)) + background.paste(img, mask=img.split()[3]) + img = background + + # Save with DPI metadata + img.save(output_file, dpi=(dpi, dpi)) + + except Exception as e: + progress.stop() + console.print(f"[red]Error processing image: {e}[/red]") + raise SystemExit(1) + + progress.update(task, description="Processing complete!") + + # Verify output + if output_file.exists(): + size_kb = output_file.stat().st_size / 1024 + console.print(f"\n[green]✓ Exported:[/green] {output_file}") + console.print(f"[dim] Size: {size_kb:.1f} KB[/dim]") + + with Image.open(output_file) as img: + width, height = img.size + console.print(f"[dim] Dimensions: {width}×{height} px[/dim]") + else: + console.print("[red]Error: Export failed[/red]") + raise SystemExit(1) + + +def batch_export( + design_type: Optional[str] = None, + status: str = "active", + dpi: int = 300, + format: str = "png" +) -> None: + """Export all designs matching criteria.""" + designs_exported = 0 + errors = [] + + for category_dir in DESIGNS_DIR.iterdir(): + if not category_dir.is_dir(): + continue + + if design_type and category_dir.name != design_type: + continue + + for design_dir in category_dir.iterdir(): + if not design_dir.is_dir(): + continue + + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + continue + + try: + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + except Exception: + continue + + design_status = metadata.get("status", "unknown") + if design_status != status: + continue + + path = f"{category_dir.name}/{design_dir.name}" + console.print(f"\n[bold]Exporting: {path}[/bold]") + + try: + export_design(path, dpi, format) + designs_exported += 1 + except SystemExit: + errors.append(path) + + console.print(f"\n[green]Exported {designs_exported} design(s)[/green]") + if errors: + console.print(f"[red]Failed: {len(errors)} design(s)[/red]") + for e in errors: + console.print(f" [red]• {e}[/red]") diff --git a/cli/mycopunk/pod/__init__.py b/cli/mycopunk/pod/__init__.py new file mode 100644 index 0000000..0785b43 --- /dev/null +++ b/cli/mycopunk/pod/__init__.py @@ -0,0 +1,9 @@ +""" +Print-on-demand provider integrations. +""" + +from .base import PODProvider +from .printful import PrintfulProvider +from .prodigi import ProdigiProvider + +__all__ = ["PODProvider", "PrintfulProvider", "ProdigiProvider"] diff --git a/cli/mycopunk/pod/base.py b/cli/mycopunk/pod/base.py new file mode 100644 index 0000000..38dc512 --- /dev/null +++ b/cli/mycopunk/pod/base.py @@ -0,0 +1,152 @@ +""" +Base class for POD provider integrations. +""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional, Any +from dataclasses import dataclass + + +@dataclass +class ProductResult: + """Result of a product creation/update operation.""" + success: bool + product_id: Optional[str] = None + error: Optional[str] = None + data: Optional[dict] = None + + +@dataclass +class MockupResult: + """Result of a mockup generation operation.""" + success: bool + image_url: Optional[str] = None + local_path: Optional[Path] = None + error: Optional[str] = None + + +class PODProvider(ABC): + """Abstract base class for print-on-demand providers.""" + + name: str = "base" + + def __init__(self, api_key: str, sandbox: bool = False) -> None: + self.api_key = api_key + self.sandbox = sandbox + self._validate_credentials() + + @abstractmethod + def _validate_credentials(self) -> None: + """Validate API credentials.""" + pass + + @abstractmethod + async def create_product( + self, + design_path: Path, + metadata: dict, + product_config: dict, + ) -> ProductResult: + """ + Create a new product on the POD service. + + Args: + design_path: Path to the design file (PNG) + metadata: Design metadata from metadata.yaml + product_config: Product configuration from products.yaml + + Returns: + ProductResult with product_id if successful + """ + pass + + @abstractmethod + async def update_product( + self, + product_id: str, + design_path: Path, + metadata: dict, + ) -> ProductResult: + """ + Update an existing product. + + Args: + product_id: Provider's product ID + design_path: Path to updated design file + metadata: Updated metadata + + Returns: + ProductResult indicating success/failure + """ + pass + + @abstractmethod + async def generate_mockup( + self, + product_id: str, + variant: Optional[str] = None, + ) -> MockupResult: + """ + Generate a product mockup. + + Args: + product_id: Provider's product ID + variant: Optional variant (color, size) for mockup + + Returns: + MockupResult with image URL or local path + """ + pass + + @abstractmethod + async def get_product(self, product_id: str) -> Optional[dict]: + """ + Get product details from the provider. + + Args: + product_id: Provider's product ID + + Returns: + Product data dict or None if not found + """ + pass + + @abstractmethod + async def list_products(self, limit: int = 100) -> list[dict]: + """ + List products from the provider. + + Args: + limit: Maximum number of products to return + + Returns: + List of product data dicts + """ + pass + + @abstractmethod + async def get_catalog(self, product_type: Optional[str] = None) -> list[dict]: + """ + Get available products from provider catalog. + + Args: + product_type: Optional filter by type (sticker, tshirt, etc) + + Returns: + List of catalog items + """ + pass + + @abstractmethod + async def upload_file(self, file_path: Path) -> Optional[str]: + """ + Upload a file to the provider. + + Args: + file_path: Local path to file + + Returns: + File URL or ID from provider, or None on failure + """ + pass diff --git a/cli/mycopunk/pod/printful.py b/cli/mycopunk/pod/printful.py new file mode 100644 index 0000000..4fa8ca5 --- /dev/null +++ b/cli/mycopunk/pod/printful.py @@ -0,0 +1,325 @@ +""" +Printful API integration. + +API Documentation: https://developers.printful.com/docs/ +""" + +from pathlib import Path +from typing import Optional +import httpx + +from .base import PODProvider, ProductResult, MockupResult + + +class PrintfulProvider(PODProvider): + """Printful print-on-demand provider.""" + + name = "printful" + + BASE_URL = "https://api.printful.com" + + def __init__(self, api_key: str, sandbox: bool = False) -> None: + super().__init__(api_key, sandbox) + self.client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + timeout=30.0, + ) + + def _validate_credentials(self) -> None: + """Validate Printful API token.""" + if not self.api_key: + raise ValueError("Printful API token is required") + # Note: Printful doesn't have a separate sandbox environment + # Test mode is controlled per-store + + async def create_product( + self, + design_path: Path, + metadata: dict, + product_config: dict, + ) -> ProductResult: + """Create a sync product on Printful.""" + try: + # 1. Upload the design file + file_url = await self.upload_file(design_path) + if not file_url: + return ProductResult( + success=False, + error="Failed to upload design file" + ) + + # 2. Build sync product payload + sku = product_config.get("sku") + colors = product_config.get("colors", ["black"]) + sizes = product_config.get("sizes", ["M"]) + placement = product_config.get("placement", "front") + + # Build sync variants + sync_variants = [] + for color in colors: + for size in sizes: + sync_variants.append({ + "variant_id": self._get_variant_id(sku, color, size), + "files": [ + { + "url": file_url, + "type": placement, + } + ], + "retail_price": str(product_config.get("retail_price", "25.00")), + }) + + payload = { + "sync_product": { + "name": metadata.get("name", "Mycopunk Design"), + "thumbnail": file_url, + }, + "sync_variants": sync_variants, + } + + # 3. Create the product + response = await self.client.post("/store/products", json=payload) + response.raise_for_status() + data = response.json() + + product_id = str(data.get("result", {}).get("id")) + + return ProductResult( + success=True, + product_id=product_id, + data=data.get("result"), + ) + + except httpx.HTTPStatusError as e: + return ProductResult( + success=False, + error=f"HTTP {e.response.status_code}: {e.response.text}" + ) + except Exception as e: + return ProductResult(success=False, error=str(e)) + + async def update_product( + self, + product_id: str, + design_path: Path, + metadata: dict, + ) -> ProductResult: + """Update an existing sync product.""" + try: + # Upload new design + file_url = await self.upload_file(design_path) + if not file_url: + return ProductResult( + success=False, + error="Failed to upload design file" + ) + + # Get current product to update variants + current = await self.get_product(product_id) + if not current: + return ProductResult( + success=False, + error=f"Product {product_id} not found" + ) + + # Update product name/thumbnail + payload = { + "sync_product": { + "name": metadata.get("name"), + "thumbnail": file_url, + } + } + + response = await self.client.put( + f"/store/products/{product_id}", + json=payload + ) + response.raise_for_status() + + # Update each variant's files + for variant in current.get("sync_variants", []): + variant_id = variant.get("id") + await self.client.put( + f"/store/variants/{variant_id}", + json={ + "files": [{"url": file_url, "type": "front"}] + } + ) + + return ProductResult( + success=True, + product_id=product_id, + ) + + except Exception as e: + return ProductResult(success=False, error=str(e)) + + async def generate_mockup( + self, + product_id: str, + variant: Optional[str] = None, + ) -> MockupResult: + """Generate product mockup using Printful's mockup generator.""" + try: + # Get product info + product = await self.get_product(product_id) + if not product: + return MockupResult( + success=False, + error=f"Product {product_id} not found" + ) + + # Get first variant or specified variant + variants = product.get("sync_variants", []) + if not variants: + return MockupResult( + success=False, + error="No variants found" + ) + + target_variant = variants[0] + if variant: + for v in variants: + if variant in str(v.get("name", "")).lower(): + target_variant = v + break + + # Request mockup generation + variant_id = target_variant.get("variant_id") + files = target_variant.get("files", []) + + if not files: + return MockupResult( + success=False, + error="No files attached to variant" + ) + + payload = { + "variant_ids": [variant_id], + "files": [{"placement": f.get("type"), "image_url": f.get("url")} for f in files], + } + + response = await self.client.post( + "/mockup-generator/create-task/variant_ids", + json=payload + ) + response.raise_for_status() + data = response.json() + + task_key = data.get("result", {}).get("task_key") + + # Poll for result (simplified - in production, use proper polling) + import asyncio + for _ in range(10): + await asyncio.sleep(2) + status_response = await self.client.get( + f"/mockup-generator/task?task_key={task_key}" + ) + status_data = status_response.json() + + if status_data.get("result", {}).get("status") == "completed": + mockups = status_data["result"]["mockups"] + if mockups: + return MockupResult( + success=True, + image_url=mockups[0]["mockup_url"], + ) + + return MockupResult( + success=False, + error="Mockup generation timed out" + ) + + except Exception as e: + return MockupResult(success=False, error=str(e)) + + async def get_product(self, product_id: str) -> Optional[dict]: + """Get sync product details.""" + try: + response = await self.client.get(f"/store/products/{product_id}") + response.raise_for_status() + data = response.json() + return data.get("result") + except Exception: + return None + + async def list_products(self, limit: int = 100) -> list[dict]: + """List all sync products.""" + try: + response = await self.client.get( + "/store/products", + params={"limit": limit} + ) + response.raise_for_status() + data = response.json() + return data.get("result", []) + except Exception: + return [] + + async def get_catalog(self, product_type: Optional[str] = None) -> list[dict]: + """Get Printful product catalog.""" + try: + response = await self.client.get("/products") + response.raise_for_status() + data = response.json() + + products = data.get("result", []) + + if product_type: + # Filter by type (approximate matching) + type_map = { + "tshirt": ["t-shirt", "tee"], + "hoodie": ["hoodie", "sweatshirt"], + "sticker": ["sticker"], + } + keywords = type_map.get(product_type, [product_type]) + products = [ + p for p in products + if any(kw in p.get("title", "").lower() for kw in keywords) + ] + + return products + except Exception: + return [] + + async def upload_file(self, file_path: Path) -> Optional[str]: + """Upload a file to Printful.""" + try: + # Printful accepts URLs, so we need to either: + # 1. Use their file library (POST /files) + # 2. Host the file elsewhere and provide URL + + # For now, use file library + with open(file_path, "rb") as f: + files = {"file": (file_path.name, f, "image/png")} + response = await self.client.post( + "/files", + files=files, + headers={"Content-Type": None}, # Let httpx set multipart + ) + + response.raise_for_status() + data = response.json() + + return data.get("result", {}).get("preview_url") + + except Exception: + return None + + def _get_variant_id(self, product_id: int, color: str, size: str) -> int: + """ + Get variant ID for a product+color+size combination. + + In production, this would query the catalog to find the right variant. + For now, returns a placeholder. + """ + # TODO: Implement proper variant lookup from catalog + return 0 + + async def close(self) -> None: + """Close the HTTP client.""" + await self.client.aclose() diff --git a/cli/mycopunk/pod/prodigi.py b/cli/mycopunk/pod/prodigi.py new file mode 100644 index 0000000..e41fbb5 --- /dev/null +++ b/cli/mycopunk/pod/prodigi.py @@ -0,0 +1,275 @@ +""" +Prodigi API integration. + +API Documentation: https://www.prodigi.com/print-api/docs/ +""" + +from pathlib import Path +from typing import Optional +import httpx + +from .base import PODProvider, ProductResult, MockupResult + + +class ProdigiProvider(PODProvider): + """Prodigi print-on-demand provider.""" + + name = "prodigi" + + SANDBOX_URL = "https://api.sandbox.prodigi.com/v4.0" + PRODUCTION_URL = "https://api.prodigi.com/v4.0" + + def __init__(self, api_key: str, sandbox: bool = False) -> None: + super().__init__(api_key, sandbox) + base_url = self.SANDBOX_URL if sandbox else self.PRODUCTION_URL + self.client = httpx.AsyncClient( + base_url=base_url, + headers={ + "X-API-Key": api_key, + "Content-Type": "application/json", + }, + timeout=30.0, + ) + + def _validate_credentials(self) -> None: + """Validate Prodigi API key.""" + if not self.api_key: + raise ValueError("Prodigi API key is required") + + async def create_product( + self, + design_path: Path, + metadata: dict, + product_config: dict, + ) -> ProductResult: + """ + Create a product template on Prodigi. + + Note: Prodigi doesn't have a "product" concept like Printful. + Instead, orders are placed directly with product SKUs and image URLs. + This method creates a "template" in our system for tracking. + """ + try: + # Upload the design file + file_url = await self.upload_file(design_path) + if not file_url: + return ProductResult( + success=False, + error="Failed to upload design file" + ) + + # Prodigi works on-demand, so we just validate the SKU exists + sku = product_config.get("sku") + + # Validate SKU by checking catalog + catalog = await self.get_catalog() + valid_skus = [p.get("sku") for p in catalog] + + if sku and sku not in valid_skus: + return ProductResult( + success=False, + error=f"Invalid SKU: {sku}" + ) + + # Return a "virtual" product ID for tracking + product_id = f"prodigi_{metadata.get('slug')}_{sku}" + + return ProductResult( + success=True, + product_id=product_id, + data={ + "sku": sku, + "image_url": file_url, + "name": metadata.get("name"), + "retail_price": product_config.get("retail_price"), + }, + ) + + except Exception as e: + return ProductResult(success=False, error=str(e)) + + async def update_product( + self, + product_id: str, + design_path: Path, + metadata: dict, + ) -> ProductResult: + """Update product by re-uploading the design file.""" + try: + file_url = await self.upload_file(design_path) + if not file_url: + return ProductResult( + success=False, + error="Failed to upload design file" + ) + + return ProductResult( + success=True, + product_id=product_id, + data={"image_url": file_url}, + ) + + except Exception as e: + return ProductResult(success=False, error=str(e)) + + async def generate_mockup( + self, + product_id: str, + variant: Optional[str] = None, + ) -> MockupResult: + """ + Generate product mockup. + + Note: Prodigi doesn't have a built-in mockup generator. + This would need to use a third-party service or custom solution. + """ + return MockupResult( + success=False, + error="Prodigi does not provide mockup generation. Use external mockup service." + ) + + async def get_product(self, product_id: str) -> Optional[dict]: + """ + Get product details. + + Since Prodigi doesn't store products, this returns None. + Product data should be stored locally in metadata.yaml. + """ + return None + + async def list_products(self, limit: int = 100) -> list[dict]: + """ + List products. + + Since Prodigi doesn't store products, returns empty list. + Use local design list instead. + """ + return [] + + async def get_catalog(self, product_type: Optional[str] = None) -> list[dict]: + """Get Prodigi product catalog.""" + try: + response = await self.client.get("/products") + response.raise_for_status() + data = response.json() + + products = data.get("products", []) + + if product_type: + # Filter by type + type_keywords = { + "sticker": ["sticker", "sti"], + "print": ["print", "poster", "art"], + "canvas": ["canvas"], + } + keywords = type_keywords.get(product_type, [product_type]) + products = [ + p for p in products + if any(kw in p.get("sku", "").lower() or + kw in p.get("description", "").lower() + for kw in keywords) + ] + + return products + + except Exception: + return [] + + async def upload_file(self, file_path: Path) -> Optional[str]: + """ + Upload a file to Prodigi. + + Note: Prodigi expects image URLs, not uploaded files. + You need to host the image somewhere accessible. + + Options: + 1. Use Cloudflare R2/S3 + 2. Use a public URL from your server + 3. Use Prodigi's asset upload (if available) + """ + # For now, return None - in production, implement actual upload + # to your hosting service (R2, S3, etc.) + return None + + async def create_order( + self, + sku: str, + image_url: str, + recipient: dict, + quantity: int = 1, + ) -> dict: + """ + Create an order on Prodigi. + + Args: + sku: Product SKU + image_url: URL to the design image + recipient: Shipping address dict + quantity: Number of items + + Returns: + Order data from Prodigi + """ + try: + payload = { + "shippingMethod": "Standard", + "recipient": recipient, + "items": [ + { + "sku": sku, + "copies": quantity, + "assets": [ + { + "printArea": "default", + "url": image_url, + } + ], + } + ], + } + + response = await self.client.post("/orders", json=payload) + response.raise_for_status() + return response.json() + + except Exception as e: + return {"error": str(e)} + + async def get_order(self, order_id: str) -> Optional[dict]: + """Get order status from Prodigi.""" + try: + response = await self.client.get(f"/orders/{order_id}") + response.raise_for_status() + return response.json() + except Exception: + return None + + async def get_quote( + self, + sku: str, + destination_country: str = "US", + quantity: int = 1, + ) -> Optional[dict]: + """Get a price quote for a product.""" + try: + payload = { + "shippingMethod": "Standard", + "destinationCountryCode": destination_country, + "items": [ + { + "sku": sku, + "copies": quantity, + } + ], + } + + response = await self.client.post("/quotes", json=payload) + response.raise_for_status() + return response.json() + + except Exception: + return None + + async def close(self) -> None: + """Close the HTTP client.""" + await self.client.aclose() diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..0b280ee --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mycopunk" +version = "0.1.0" +description = "CLI tool for managing mycopunk merchandise and POD fulfillment" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "Jeff Emmett", email = "jeff@jeffemmett.com" } +] +keywords = ["merchandise", "print-on-demand", "cli", "design"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Graphics", +] + +dependencies = [ + "typer[all]>=0.9.0", + "rich>=13.0.0", + "pyyaml>=6.0", + "httpx>=0.25.0", + "python-dotenv>=1.0.0", + "pillow>=10.0.0", + "pydantic>=2.0.0", + "jinja2>=3.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "pytest-asyncio>=0.21.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.5.0", + "types-PyYAML>=6.0.0", +] + +[project.scripts] +mycopunk = "mycopunk.cli:app" + +[project.urls] +Homepage = "https://gitea.jeffemmett.com/jeffemmett/mycopunk-swag" +Documentation = "https://gitea.jeffemmett.com/jeffemmett/mycopunk-swag/wiki" +Repository = "https://gitea.jeffemmett.com/jeffemmett/mycopunk-swag.git" +Issues = "https://gitea.jeffemmett.com/jeffemmett/mycopunk-swag/issues" + +[tool.hatch.build.targets.wheel] +packages = ["mycopunk"] + +[tool.black] +line-length = 100 +target-version = ["py310", "py311", "py312"] + +[tool.ruff] +line-length = 100 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by black) +] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/cli/tests/__init__.py b/cli/tests/__init__.py new file mode 100644 index 0000000..47da48c --- /dev/null +++ b/cli/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for mycopunk CLI.""" diff --git a/cli/tests/test_design.py b/cli/tests/test_design.py new file mode 100644 index 0000000..f93b7c4 --- /dev/null +++ b/cli/tests/test_design.py @@ -0,0 +1,58 @@ +"""Tests for design management commands.""" + +import pytest +from pathlib import Path +import tempfile +import shutil + +from mycopunk.design import create_design, validate_design, list_designs + + +class TestCreateDesign: + """Tests for design creation.""" + + def test_create_design_creates_directory_structure(self, tmp_path): + """Test that create_design creates the expected directory structure.""" + # This would need mocking of DESIGNS_DIR + # For now, this is a placeholder for the test structure + pass + + def test_create_design_with_invalid_path_raises_error(self): + """Test that invalid paths raise an error.""" + pass + + def test_create_design_with_unknown_template_raises_error(self): + """Test that unknown templates raise an error.""" + pass + + +class TestValidateDesign: + """Tests for design validation.""" + + def test_validate_missing_design_raises_error(self): + """Test that missing designs raise an error.""" + pass + + def test_validate_missing_metadata_raises_error(self): + """Test that missing metadata.yaml raises an error.""" + pass + + def test_validate_complete_design_passes(self): + """Test that a complete design passes validation.""" + pass + + +class TestListDesigns: + """Tests for design listing.""" + + def test_list_empty_designs_directory(self): + """Test listing when no designs exist.""" + pass + + def test_list_with_status_filter(self): + """Test filtering designs by status.""" + pass + + def test_list_with_type_filter(self): + """Test filtering designs by type.""" + pass diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 0000000..0299e1a --- /dev/null +++ b/config/.env.example @@ -0,0 +1,39 @@ +# Mycopunk Swag - Environment Variables +# Copy this file to .env and fill in your API credentials +# NEVER commit .env to version control + +# =========================================== +# Printful API +# Get your token at: https://developers.printful.com/ +# =========================================== +PRINTFUL_API_TOKEN=your_printful_api_token_here + +# =========================================== +# Prodigi API +# Get keys at: https://dashboard.prodigi.com/ +# =========================================== +# Sandbox key for testing (orders not fulfilled) +PRODIGI_API_KEY_SANDBOX=your_prodigi_sandbox_key_here + +# Live key for production orders +PRODIGI_API_KEY_LIVE=your_prodigi_live_key_here + +# =========================================== +# Stripe (for custom shop - Phase 5) +# Get keys at: https://dashboard.stripe.com/ +# =========================================== +# STRIPE_SECRET_KEY=sk_test_xxx +# STRIPE_WEBHOOK_SECRET=whsec_xxx +# STRIPE_PUBLISHABLE_KEY=pk_test_xxx + +# =========================================== +# Application Settings +# =========================================== +# Set to 'production' when ready for live orders +MYCOPUNK_ENV=development + +# Default POD provider (printful or prodigi) +DEFAULT_PROVIDER=prodigi + +# Enable debug logging +DEBUG=false diff --git a/config/products.yaml b/config/products.yaml new file mode 100644 index 0000000..d9282c4 --- /dev/null +++ b/config/products.yaml @@ -0,0 +1,232 @@ +# Mycopunk Product Catalog Configuration +# Maps design types to POD provider products + +defaults: + color_profile: sRGB + resolution: 300 + format: png + +# Sticker Products +stickers: + small: + name: "3×3 Vinyl Sticker" + dimensions: + width: 3 + height: 3 + unit: inches + pixels: + width: 900 + height: 900 + providers: + prodigi: + sku: "GLOBAL-STI-KIS-3X3" + variants: + - id: matte + name: "Matte Finish" + - id: gloss + name: "Gloss Finish" + base_cost: 1.20 + printful: + sku: 358 # Kiss-cut stickers + variants: + - id: white + name: "White" + base_cost: 1.50 + + medium: + name: "4×4 Vinyl Sticker" + dimensions: + width: 4 + height: 4 + unit: inches + pixels: + width: 1200 + height: 1200 + providers: + prodigi: + sku: "GLOBAL-STI-KIS-4X4" + variants: + - id: matte + name: "Matte Finish" + - id: gloss + name: "Gloss Finish" + base_cost: 1.80 + + large: + name: "6×6 Vinyl Sticker" + dimensions: + width: 6 + height: 6 + unit: inches + pixels: + width: 1800 + height: 1800 + providers: + prodigi: + sku: "GLOBAL-STI-KIS-6X6" + base_cost: 2.50 + +# Apparel Products +apparel: + tshirt: + name: "Unisex T-Shirt" + print_areas: + front: + dimensions: + width: 12 + height: 16 + unit: inches + pixels: + width: 3600 + height: 4800 + chest: + dimensions: + width: 4 + height: 4 + unit: inches + pixels: + width: 1200 + height: 1200 + providers: + printful: + sku: 71 # Bella + Canvas 3001 + sizes: [S, M, L, XL, 2XL, 3XL] + colors: + - id: black + name: "Black" + hex: "#0a0a0a" + - id: white + name: "White" + hex: "#ffffff" + - id: heather_charcoal + name: "Heather Charcoal" + hex: "#4a4a4a" + - id: forest_green + name: "Forest Green" + hex: "#2d4a3e" + - id: maroon + name: "Maroon" + hex: "#5a2d2d" + base_cost: + S: 9.25 + M: 9.25 + L: 9.25 + XL: 9.25 + 2XL: 11.25 + 3XL: 13.25 + + hoodie: + name: "Unisex Hoodie" + print_areas: + front: + dimensions: + width: 14 + height: 16 + unit: inches + pixels: + width: 4200 + height: 4800 + providers: + printful: + sku: 146 # Bella + Canvas 3719 + sizes: [S, M, L, XL, 2XL] + colors: + - id: black + name: "Black" + - id: dark_grey_heather + name: "Dark Grey Heather" + base_cost: + S: 23.95 + M: 23.95 + L: 23.95 + XL: 23.95 + 2XL: 27.95 + +# Art Prints +prints: + small: + name: "8×10 Art Print" + dimensions: + width: 8 + height: 10 + unit: inches + pixels: + width: 2400 + height: 3000 + providers: + prodigi: + sku: "GLOBAL-FAP-8X10" + variants: + - id: matte + name: "Matte" + - id: lustre + name: "Lustre" + base_cost: 4.50 + + medium: + name: "11×14 Art Print" + dimensions: + width: 11 + height: 14 + unit: inches + pixels: + width: 3300 + height: 4200 + providers: + prodigi: + sku: "GLOBAL-FAP-11X14" + base_cost: 7.00 + + large: + name: "18×24 Art Print" + dimensions: + width: 18 + height: 24 + unit: inches + pixels: + width: 5400 + height: 7200 + providers: + prodigi: + sku: "GLOBAL-FAP-18X24" + base_cost: 12.00 + +# Pricing Rules +pricing: + default_markup: 2.0 # 100% markup (double cost) + + rules: + stickers: + markup: 2.5 # Higher margin on low-cost items + minimum_price: 3.00 + + apparel: + markup: 1.8 + minimum_price: 20.00 + + prints: + markup: 2.0 + minimum_price: 15.00 + + # Round to nearest .99 or .50 + rounding: nearest_99 + +# Shipping Profiles +shipping: + prodigi: + standard: + name: "Standard" + days: "5-10" + express: + name: "Express" + days: "2-5" + additional_cost: 5.00 + + printful: + standard: + name: "Standard" + days: "5-12" + express: + name: "Express" + days: "3-5" + additional_cost: 7.00 diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..9051d7a --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,378 @@ +# CLI Reference + +Complete command reference for the `mycopunk` CLI tool. + +## Installation + +```bash +cd cli +pip install -e . +``` + +This installs the CLI in development mode, allowing you to edit the code and see changes immediately. + +## Global Options + +```bash +mycopunk --version # Show version +mycopunk --help # Show help +``` + +--- + +## Design Commands + +### `mycopunk design new` + +Create a new design scaffold with metadata template. + +```bash +mycopunk design new [--template TEMPLATE] +``` + +**Arguments:** +- `path` - Design path in format `category/design-name` (e.g., `stickers/my-design`) + +**Options:** +- `--template, -t` - Template to use (default: `sticker-3x3`) + +**Available Templates:** +| Template | Dimensions | Use Case | +|----------|-----------|----------| +| `sticker-3x3` | 900×900 px | Small vinyl sticker | +| `sticker-4x4` | 1200×1200 px | Medium vinyl sticker | +| `sticker-6x6` | 1800×1800 px | Large vinyl sticker | +| `tshirt-front` | 3600×4800 px | Full front print | +| `tshirt-chest` | 1200×1200 px | Chest/pocket print | +| `print-8x10` | 2400×3000 px | 8×10 art print | + +**Example:** +```bash +mycopunk design new stickers/spore-spiral --template=sticker-3x3 +mycopunk design new shirts/mycelium-network --template=tshirt-front +``` + +**Creates:** +``` +designs/stickers/spore-spiral/ +├── spore-spiral.svg # Edit this in Inkscape +├── metadata.yaml # Configure products here +└── exports/ + ├── 300dpi/.gitkeep + └── mockups/.gitkeep +``` + +--- + +### `mycopunk design list` + +List all designs in the repository. + +```bash +mycopunk design list [--status STATUS] [--type TYPE] +``` + +**Options:** +- `--status, -s` - Filter by status (draft, active, retired) +- `--type, -t` - Filter by category (stickers, shirts, misc) + +**Example:** +```bash +mycopunk design list +mycopunk design list --status=active +mycopunk design list --type=stickers --status=active +``` + +--- + +### `mycopunk design export` + +Export design to print-ready files. + +```bash +mycopunk design export [--dpi DPI] [--format FORMAT] +``` + +**Arguments:** +- `path` - Design path (e.g., `stickers/my-design`) + +**Options:** +- `--dpi` - Export resolution (default: 300) +- `--format, -f` - Export format: png, jpg (default: png) + +**Example:** +```bash +mycopunk design export stickers/spore-spiral +mycopunk design export stickers/spore-spiral --dpi=600 +``` + +**Requirements:** +- Inkscape must be installed for SVG export +- PIL/Pillow for raster format conversion + +--- + +### `mycopunk design validate` + +Validate design meets POD requirements. + +```bash +mycopunk design validate [--strict] +``` + +**Arguments:** +- `path` - Design path to validate + +**Options:** +- `--strict` - Fail on warnings (not just errors) + +**Checks:** +- ✓ Required metadata fields present +- ✓ Source file exists +- ✓ Exported PNG dimensions match spec +- ✓ Resolution meets minimum (300 DPI) +- ✓ Products configured correctly + +**Example:** +```bash +mycopunk design validate stickers/spore-spiral +mycopunk design validate stickers/spore-spiral --strict +``` + +--- + +## Product Commands + +### `mycopunk product create` + +Create product on POD service from design. + +```bash +mycopunk product create [--provider PROVIDER] [--sandbox] +``` + +**Arguments:** +- `path` - Design path + +**Options:** +- `--provider, -p` - POD provider: printful, prodigi (default: prodigi) +- `--sandbox` - Use sandbox/test mode (no real orders) + +**Example:** +```bash +mycopunk product create stickers/spore-spiral --provider=prodigi --sandbox +mycopunk product create shirts/mycelium-network --provider=printful +``` + +--- + +### `mycopunk product push` + +Push design updates to POD product. + +```bash +mycopunk product push [--provider PROVIDER] +``` + +**Arguments:** +- `path` - Design path + +**Options:** +- `--provider, -p` - Specific provider (default: all configured) + +**Example:** +```bash +mycopunk product push stickers/spore-spiral +mycopunk product push stickers/spore-spiral --provider=printful +``` + +--- + +### `mycopunk product list` + +List all products by provider. + +```bash +mycopunk product list [--provider PROVIDER] +``` + +**Options:** +- `--provider, -p` - Filter by provider + +**Example:** +```bash +mycopunk product list +mycopunk product list --provider=printful +``` + +--- + +## Mockup Commands + +### `mycopunk mockup generate` + +Generate product mockups for a design. + +```bash +mycopunk mockup generate [--all] [--color COLOR] [--size SIZE] +``` + +**Arguments:** +- `path` - Design path + +**Options:** +- `--all` - Generate all variant mockups +- `--color, -c` - Product color (e.g., black, white) +- `--size, -s` - Product size (e.g., M, L, XL) + +**Example:** +```bash +mycopunk mockup generate stickers/spore-spiral --all +mycopunk mockup generate shirts/mycelium-network --color=black --size=L +``` + +--- + +## Catalog Commands + +### `mycopunk catalog sync` + +Sync product catalog from POD services. + +```bash +mycopunk catalog sync [--provider PROVIDER] +``` + +**Options:** +- `--provider, -p` - Provider to sync: printful, prodigi, all (default: all) + +**Example:** +```bash +mycopunk catalog sync +mycopunk catalog sync --provider=printful +``` + +--- + +## Batch Commands + +### `mycopunk batch export` + +Export all designs matching criteria. + +```bash +mycopunk batch export [--type TYPE] [--status STATUS] +``` + +**Options:** +- `--type, -t` - Filter by category +- `--status, -s` - Filter by status (default: active) + +**Example:** +```bash +mycopunk batch export --type=sticker +mycopunk batch export --status=active +``` + +--- + +### `mycopunk batch push` + +Push all designs to POD services. + +```bash +mycopunk batch push [--status STATUS] [--provider PROVIDER] +``` + +**Options:** +- `--status, -s` - Filter by status (default: active) +- `--provider, -p` - Specific provider + +**Example:** +```bash +mycopunk batch push --status=active +mycopunk batch push --provider=prodigi +``` + +--- + +## Reports + +### `mycopunk report` + +Generate various reports. + +```bash +mycopunk report [--output FILE] +``` + +**Arguments:** +- `type` - Report type: pricing, inventory, orders + +**Options:** +- `--output, -o` - Output file path + +**Example:** +```bash +mycopunk report pricing +mycopunk report pricing --output=pricing.csv +``` + +--- + +## Environment Variables + +Set these in `config/.env`: + +| Variable | Description | Required | +|----------|-------------|----------| +| `PRINTFUL_API_TOKEN` | Printful API token | For Printful operations | +| `PRODIGI_API_KEY_SANDBOX` | Prodigi sandbox key | For testing | +| `PRODIGI_API_KEY_LIVE` | Prodigi production key | For live orders | +| `MYCOPUNK_ENV` | Environment: development/production | No (default: development) | +| `DEFAULT_PROVIDER` | Default POD provider | No (default: prodigi) | +| `DEBUG` | Enable debug logging | No (default: false) | + +--- + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Error (validation failed, API error, etc.) | + +--- + +## Tips + +### Quick Workflow + +```bash +# Create a new sticker design +mycopunk design new stickers/mushroom-logo --template=sticker-3x3 + +# Edit the SVG in Inkscape +inkscape designs/stickers/mushroom-logo/mushroom-logo.svg + +# Edit metadata.yaml to set pricing + +# Export to PNG +mycopunk design export stickers/mushroom-logo + +# Validate before pushing +mycopunk design validate stickers/mushroom-logo + +# Create on Prodigi (test mode) +mycopunk product create stickers/mushroom-logo --provider=prodigi --sandbox +``` + +### Batch Operations + +```bash +# Export all active stickers +mycopunk batch export --type=stickers --status=active + +# Push everything to production +MYCOPUNK_ENV=production mycopunk batch push --status=active +``` diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md new file mode 100644 index 0000000..7864038 --- /dev/null +++ b/docs/design-guidelines.md @@ -0,0 +1,261 @@ +# Design Guidelines + +This document covers technical requirements and best practices for creating print-ready designs for mycopunk merchandise. + +## General Requirements + +### File Formats + +| Format | Use Case | Notes | +|--------|----------|-------| +| **SVG** | Source files | Preferred for all designs; scales infinitely | +| **PNG** | Print exports | Transparent background, 300 DPI | +| **JPEG** | Photo-based art | Only when transparency not needed | + +### Resolution + +**All exports must be 300 DPI minimum.** + +| Product | Physical Size | Pixels @ 300 DPI | +|---------|---------------|------------------| +| T-shirt (front) | 12" × 16" | 3600 × 4800 px | +| T-shirt (chest pocket) | 4" × 4" | 1200 × 1200 px | +| Hoodie (front) | 14" × 16" | 4200 × 4800 px | +| Sticker (small) | 3" × 3" | 900 × 900 px | +| Sticker (medium) | 4" × 4" | 1200 × 1200 px | +| Sticker (large) | 6" × 6" | 1800 × 1800 px | +| Art print (8×10) | 8" × 10" | 2400 × 3000 px | +| Art print (11×14) | 11" × 14" | 3300 × 4200 px | + +### Color Profile + +- **sRGB** for all designs +- Avoid CMYK (POD services convert automatically) +- Be aware colors may shift slightly in print + +### Transparency + +- Use transparent backgrounds for most designs +- POD services can apply designs to any color product +- Exception: full-coverage prints (all-over print shirts) + +--- + +## Sticker-Specific Guidelines + +### Types + +| Type | Description | Best For | +|------|-------------|----------| +| **Die-cut** | Cut to shape of design | Logos, characters | +| **Kiss-cut** | Cut on backing, easy peel | Complex shapes | +| **Rectangle** | Standard rectangle cut | Text-heavy designs | + +### Design Tips + +1. **Bleed area**: Add 1/16" (0.0625") bleed around edges +2. **Safe zone**: Keep important elements 1/8" from edge +3. **Minimum line width**: 0.02" for visibility +4. **White border**: Add 1/16" white border if design touches edges + +### Prodigi Sticker Specs + +```yaml +# Standard kiss-cut vinyl sticker +format: PNG +resolution: 300 DPI +color_mode: sRGB +background: transparent +max_file_size: 25 MB +``` + +--- + +## Apparel-Specific Guidelines + +### Print Areas + +``` +┌─────────────────────────────────────┐ +│ T-SHIRT FRONT │ +│ │ +│ ┌───────────────────────┐ │ +│ │ │ │ +│ │ MAX PRINT AREA │ │ +│ │ 12" × 16" │ │ +│ │ 3600 × 4800 px │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └───────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ + +┌─────────────────────────────────────┐ +│ CHEST/POCKET AREA │ +│ │ +│ ┌─────────┐ │ +│ │ 4" × 4" │ Left chest │ +│ │ │ 1200 × 1200 px │ +│ └─────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Design Tips + +1. **Avoid edges**: Design won't print to garment edges +2. **Consider fabric color**: Light designs on dark shirts, vice versa +3. **Simplify gradients**: DTG printing can struggle with subtle gradients +4. **Minimum details**: Small text/lines may not print clearly +5. **Test at actual size**: Print a test at 100% scale before ordering + +### Printful Apparel Specs + +```yaml +# Front print area +format: PNG +resolution: 300 DPI +dimensions: 3600 × 4800 px (12" × 16") +color_mode: sRGB +background: transparent +max_file_size: 200 MB +``` + +--- + +## Art Print Guidelines + +### Paper Types (Prodigi) + +| Type | Finish | Best For | +|------|--------|----------| +| Matte | No glare | Photos, illustrations | +| Glossy | Reflective | Vibrant colors, pop art | +| Fine Art | Textured | Museum-quality pieces | + +### Sizing + +Standard sizes match common frames: +- 5" × 7" +- 8" × 10" +- 11" × 14" +- 16" × 20" +- 18" × 24" + +### Bleed & Safe Zone + +``` +┌─────────────────────────────────────┐ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ ← Bleed (0.125") +│ ▒ ┌───────────────────────────┐ ▒ │ +│ ▒ │ │ ▒ │ +│ ▒ │ SAFE ZONE │ ▒ │ ← Safe zone (0.25" from trim) +│ ▒ │ Keep text/important │ ▒ │ +│ ▒ │ elements here │ ▒ │ +│ ▒ │ │ ▒ │ +│ ▒ └───────────────────────────┘ ▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +└─────────────────────────────────────┘ + ↑ Trim line +``` + +--- + +## Inkscape Workflow + +### New Design Setup + +1. Create new document: `File > New` +2. Set document properties (`Ctrl+Shift+D`): + - Units: pixels + - Width/Height: Per product specs + - Scale: 1 px = 1 px +3. Save as `.svg` in design directory + +### Export Settings + +```bash +# CLI export command +inkscape design.svg \ + --export-type=png \ + --export-dpi=300 \ + --export-background-opacity=0 \ + --export-filename=design-300dpi.png +``` + +### Batch Export Script + +```bash +#!/bin/bash +# scripts/batch-export.sh + +for svg in designs/**/*.svg; do + dir=$(dirname "$svg") + base=$(basename "$svg" .svg) + mkdir -p "$dir/exports/300dpi" + + inkscape "$svg" \ + --export-type=png \ + --export-dpi=300 \ + --export-background-opacity=0 \ + --export-filename="$dir/exports/300dpi/${base}.png" +done +``` + +--- + +## GIMP Workflow + +### For Raster Designs + +1. Create new image at target pixel dimensions +2. Set resolution: `Image > Print Size > 300 pixels/inch` +3. Work in RGB mode +4. Export as PNG with transparency + +### Converting GIMP to Print-Ready + +```bash +# Export from GIMP command line +gimp -i -b '(python-fu-export-png RUN-NONINTERACTIVE "input.xcf" "output.png")' -b '(gimp-quit 0)' +``` + +--- + +## Quality Checklist + +Before exporting, verify: + +- [ ] Resolution is 300 DPI or higher +- [ ] Dimensions match product requirements +- [ ] Color profile is sRGB +- [ ] Background is transparent (unless intentional) +- [ ] No text too small (minimum 8pt at print size) +- [ ] No lines thinner than 0.5pt +- [ ] Bleed area included where required +- [ ] Important elements within safe zone + +## Validation + +Run the CLI validator before pushing: + +```bash +mycopunk design validate stickers/my-design + +# Output example: +# ✓ Resolution: 300 DPI +# ✓ Dimensions: 900 × 900 px (3" × 3") +# ✓ Color profile: sRGB +# ✓ Transparency: Yes +# ✗ Warning: Design extends into bleed area +``` + +--- + +## Resources + +- [Printful File Guidelines](https://www.printful.com/blog/everything-you-need-to-know-to-prepare-the-perfect-printfile) +- [Prodigi Print Specs](https://www.prodigi.com/print-api/docs/reference/#images) +- [Inkscape Documentation](https://inkscape.org/doc/) +- [GIMP Documentation](https://www.gimp.org/docs/) diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..ee9ec67 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,321 @@ +# Mycopunk Workflow Guide + +This document describes the end-to-end workflow for creating, managing, and fulfilling mycopunk merchandise. + +## Overview + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Design │ → │ Export │ → │ Publish │ → │ Fulfill │ +│ (Inkscape) │ │ (CLI) │ │ (POD) │ │ (Orders) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ +``` + +## Phase 1: Design Creation + +### 1.1 Create Design Scaffold + +```bash +# Create a new sticker design +mycopunk design new stickers/mycelium-mandala --template=sticker-4x4 +``` + +This creates: +``` +designs/stickers/mycelium-mandala/ +├── mycelium-mandala.svg # Source file +├── metadata.yaml # Configuration +└── exports/ # Output directory +``` + +### 1.2 Design in Inkscape + +Open the SVG file in Inkscape: + +```bash +inkscape designs/stickers/mycelium-mandala/mycelium-mandala.svg +``` + +**Design Guidelines:** +- Work at the specified canvas size (e.g., 1200×1200 px for 4×4" sticker) +- Use sRGB color profile +- Keep important elements away from edges (safe zone) +- Add 1/16" bleed if design touches edges +- Save frequently! + +### 1.3 Configure Metadata + +Edit `metadata.yaml`: + +```yaml +name: "Mycelium Mandala" +slug: mycelium-mandala +description: "Sacred geometry inspired by fungal networks" +tags: [mycelium, mandala, sacred-geometry, nature] +created: 2025-01-24 +author: jeff + +source: + file: mycelium-mandala.svg + format: svg + dimensions: + width: 1200 + height: 1200 + dpi: 300 + color_profile: sRGB + +products: + - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-4X4 + variants: [matte, gloss] + retail_price: 4.50 + + - type: sticker + provider: printful + sku: 358 + variants: [white] + retail_price: 4.50 + +status: draft # Change to 'active' when ready +``` + +--- + +## Phase 2: Export & Validate + +### 2.1 Export to Print-Ready Files + +```bash +mycopunk design export stickers/mycelium-mandala +``` + +Output: +``` +✓ Exported: designs/stickers/mycelium-mandala/exports/300dpi/mycelium-mandala.png + Size: 245.3 KB + Dimensions: 1200×1200 px +``` + +### 2.2 Validate Design + +```bash +mycopunk design validate stickers/mycelium-mandala +``` + +Output: +``` +Validating: Mycelium Mandala + + ✓ Metadata field 'name' present + ✓ Metadata field 'slug' present + ✓ Metadata field 'source' present + ✓ Metadata field 'products' present + ✓ Metadata field 'status' present + ✓ Source file exists: mycelium-mandala.svg + ✓ Dimensions correct: 1200×1200 + ✓ Products configured: 2 + +✓ Validation passed +``` + +### 2.3 Generate Local Mockups (Optional) + +For quick previews without API calls: + +```bash +./scripts/generate-mockups.sh stickers/mycelium-mandala +``` + +--- + +## Phase 3: Publish to POD + +### 3.1 Test in Sandbox First + +Always test with sandbox credentials: + +```bash +mycopunk product create stickers/mycelium-mandala --provider=prodigi --sandbox +``` + +### 3.2 Update Status + +Once satisfied, update the design status: + +```yaml +# In metadata.yaml +status: active +``` + +### 3.3 Create Production Product + +```bash +# Ensure MYCOPUNK_ENV=production or use live API keys +mycopunk product create stickers/mycelium-mandala --provider=prodigi +``` + +### 3.4 Generate Production Mockups + +```bash +mycopunk mockup generate stickers/mycelium-mandala --all +``` + +--- + +## Phase 4: Selling & Fulfillment + +### Option A: Use POD Storefronts + +Both Printful and Prodigi integrate with: +- Etsy +- Shopify +- WooCommerce +- Amazon + +Connect your accounts in their dashboards. + +### Option B: Custom Shop (Phase 5) + +Build your own storefront (see architecture in plan). + +### Order Flow + +``` +Customer Order → Payment → API Call to POD → Fulfillment → Shipping +``` + +--- + +## Batch Operations + +### Export All Active Designs + +```bash +mycopunk batch export --status=active +``` + +### Push All to POD + +```bash +mycopunk batch push --status=active --provider=prodigi +``` + +### Generate Price Report + +```bash +mycopunk report pricing > pricing.csv +``` + +--- + +## File Organization Best Practices + +### Design Directory Structure + +``` +designs/ +├── stickers/ +│ ├── mycelium-mandala/ +│ │ ├── mycelium-mandala.svg # Source (edit this) +│ │ ├── mycelium-mandala.xcf # GIMP working file (optional) +│ │ ├── metadata.yaml # Configuration +│ │ ├── reference/ # Inspiration images (optional) +│ │ └── exports/ +│ │ ├── 300dpi/ +│ │ │ └── mycelium-mandala.png +│ │ └── mockups/ +│ │ ├── sticker-mockup.png +│ │ └── product-photo.jpg +│ └── spore-spiral/ +│ └── ... +├── shirts/ +│ └── network-tee/ +│ └── ... +└── misc/ + ├── patches/ + ├── pins/ + └── zines/ +``` + +### Naming Conventions + +- **Slugs**: lowercase, hyphen-separated (e.g., `mycelium-mandala`) +- **Files**: Match slug (e.g., `mycelium-mandala.svg`) +- **Categories**: Plural (e.g., `stickers/`, `shirts/`) + +--- + +## Git Workflow + +### Committing Designs + +```bash +# Add new design +git add designs/stickers/mycelium-mandala/ + +# Don't commit exports (they're gitignored) +# They can be regenerated with mycopunk design export + +git commit -m "feat(stickers): add mycelium mandala design" +``` + +### Branching Strategy + +``` +main # Production-ready designs +└── dev # Work in progress + └── feature/new-sticker-pack +``` + +--- + +## Troubleshooting + +### Export Fails + +**Problem**: Inkscape export produces error + +**Solution**: +1. Check Inkscape is installed: `which inkscape` +2. Verify SVG is valid: open in Inkscape GUI +3. Check file permissions + +### Validation Warnings + +**Problem**: "Low resolution" warning + +**Solution**: Ensure source SVG canvas matches target dimensions at 300 DPI + +**Problem**: "No exported PNG found" + +**Solution**: Run `mycopunk design export ` first + +### API Errors + +**Problem**: "401 Unauthorized" + +**Solution**: Check API keys in `config/.env` + +**Problem**: "Invalid SKU" + +**Solution**: Sync catalog and verify SKU exists: +```bash +./scripts/sync-catalog.sh prodigi +jq '.products[] | .sku' .cache/catalogs/prodigi-catalog.json | grep -i sticker +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| New design | `mycopunk design new --template=` | +| List designs | `mycopunk design list` | +| Export design | `mycopunk design export ` | +| Validate design | `mycopunk design validate ` | +| Create product | `mycopunk product create --provider=

` | +| Push updates | `mycopunk product push ` | +| Generate mockups | `mycopunk mockup generate ` | +| Batch export | `mycopunk batch export --type=` | diff --git a/scripts/batch-export.sh b/scripts/batch-export.sh new file mode 100755 index 0000000..7c38359 --- /dev/null +++ b/scripts/batch-export.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Batch export all SVG designs to PNG at 300 DPI +# +# Usage: ./scripts/batch-export.sh [category] +# Example: ./scripts/batch-export.sh stickers + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DESIGNS_DIR="$PROJECT_DIR/designs" + +DPI=${DPI:-300} +CATEGORY=${1:-} + +echo "🍄 Mycopunk Batch Export" +echo "========================" +echo "DPI: $DPI" +echo "" + +# Check for Inkscape +if ! command -v inkscape &> /dev/null; then + echo "Error: Inkscape not found. Please install Inkscape." + echo "Ubuntu: sudo apt install inkscape" + exit 1 +fi + +count=0 +errors=0 + +# Find all SVG files +find_pattern="$DESIGNS_DIR" +if [ -n "$CATEGORY" ]; then + find_pattern="$DESIGNS_DIR/$CATEGORY" + echo "Category: $CATEGORY" +fi + +echo "" + +for svg in $(find "$find_pattern" -name "*.svg" -type f 2>/dev/null); do + dir=$(dirname "$svg") + base=$(basename "$svg" .svg) + export_dir="$dir/exports/${DPI}dpi" + output="$export_dir/${base}.png" + + # Skip template files + if [[ "$dir" == *"templates"* ]]; then + continue + fi + + echo "Exporting: $base" + + mkdir -p "$export_dir" + + if inkscape "$svg" \ + --export-type=png \ + --export-dpi="$DPI" \ + --export-background-opacity=0 \ + --export-filename="$output" 2>/dev/null; then + echo " ✓ $output" + ((count++)) + else + echo " ✗ Failed" + ((errors++)) + fi +done + +echo "" +echo "========================" +echo "Exported: $count files" +if [ $errors -gt 0 ]; then + echo "Errors: $errors" + exit 1 +fi +echo "Done!" diff --git a/scripts/generate-mockups.sh b/scripts/generate-mockups.sh new file mode 100755 index 0000000..e6920d7 --- /dev/null +++ b/scripts/generate-mockups.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Generate mockups for designs using ImageMagick compositing +# +# This is a basic local mockup generator for quick previews. +# For production mockups, use the POD APIs (Printful has great mockup generation). +# +# Usage: ./scripts/generate-mockups.sh [template] +# Example: ./scripts/generate-mockups.sh stickers/spore-spiral sticker-3x3 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DESIGNS_DIR="$PROJECT_DIR/designs" +TEMPLATES_DIR="$PROJECT_DIR/templates/mockups" + +DESIGN_PATH=${1:-} +TEMPLATE=${2:-} + +if [ -z "$DESIGN_PATH" ]; then + echo "Usage: $0 [template]" + echo "Example: $0 stickers/spore-spiral sticker-3x3" + exit 1 +fi + +# Check for ImageMagick +if ! command -v convert &> /dev/null; then + echo "Error: ImageMagick not found. Please install ImageMagick." + echo "Ubuntu: sudo apt install imagemagick" + exit 1 +fi + +DESIGN_DIR="$DESIGNS_DIR/$DESIGN_PATH" +EXPORT_DIR="$DESIGN_DIR/exports/300dpi" +MOCKUP_DIR="$DESIGN_DIR/exports/mockups" + +# Find the exported PNG +DESIGN_PNG=$(find "$EXPORT_DIR" -name "*.png" -type f | head -1) + +if [ -z "$DESIGN_PNG" ]; then + echo "Error: No exported PNG found in $EXPORT_DIR" + echo "Run 'mycopunk design export $DESIGN_PATH' first" + exit 1 +fi + +echo "🍄 Generating mockups for: $DESIGN_PATH" +echo "Using: $(basename "$DESIGN_PNG")" +echo "" + +mkdir -p "$MOCKUP_DIR" + +# Simple sticker mockup - add drop shadow and slight rotation +echo "Creating sticker mockup..." +convert "$DESIGN_PNG" \ + \( +clone -background black -shadow 60x5+5+5 \) \ + +swap -background white -layers merge +repage \ + -rotate -5 \ + "$MOCKUP_DIR/sticker-mockup.png" +echo " ✓ sticker-mockup.png" + +# T-shirt mockup placeholder +# In production, use Printful's mockup API for photorealistic results +echo "Creating simple t-shirt preview..." +convert -size 500x600 xc:'#2a2a2a' \ + "$DESIGN_PNG" -resize 200x200 -gravity center -composite \ + -gravity north -fill white -pointsize 14 -annotate +0+10 "T-SHIRT PREVIEW" \ + "$MOCKUP_DIR/tshirt-preview.png" +echo " ✓ tshirt-preview.png" + +echo "" +echo "Mockups saved to: $MOCKUP_DIR" +echo "" +echo "Note: For production-quality mockups, use the POD provider APIs:" +echo " mycopunk mockup generate $DESIGN_PATH --provider=printful" diff --git a/scripts/sync-catalog.sh b/scripts/sync-catalog.sh new file mode 100755 index 0000000..7bc71f8 --- /dev/null +++ b/scripts/sync-catalog.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Sync product catalogs from POD providers +# +# Fetches the latest product catalog from Printful and Prodigi +# and caches locally for offline reference. +# +# Usage: ./scripts/sync-catalog.sh [provider] +# Example: ./scripts/sync-catalog.sh printful + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +CACHE_DIR="$PROJECT_DIR/.cache/catalogs" + +PROVIDER=${1:-all} + +mkdir -p "$CACHE_DIR" + +echo "🍄 Syncing POD Catalogs" +echo "=======================" +echo "" + +sync_printful() { + echo "Syncing Printful catalog..." + + if [ -z "$PRINTFUL_API_TOKEN" ]; then + echo " ⚠ PRINTFUL_API_TOKEN not set, skipping" + return + fi + + curl -s -H "Authorization: Bearer $PRINTFUL_API_TOKEN" \ + "https://api.printful.com/products" \ + > "$CACHE_DIR/printful-catalog.json" + + count=$(jq '.result | length' "$CACHE_DIR/printful-catalog.json" 2>/dev/null || echo "0") + echo " ✓ Saved $count products to printful-catalog.json" +} + +sync_prodigi() { + echo "Syncing Prodigi catalog..." + + API_KEY="${PRODIGI_API_KEY_SANDBOX:-$PRODIGI_API_KEY_LIVE}" + if [ -z "$API_KEY" ]; then + echo " ⚠ PRODIGI_API_KEY not set, skipping" + return + fi + + curl -s -H "X-API-Key: $API_KEY" \ + "https://api.sandbox.prodigi.com/v4.0/products" \ + > "$CACHE_DIR/prodigi-catalog.json" + + count=$(jq '.products | length' "$CACHE_DIR/prodigi-catalog.json" 2>/dev/null || echo "0") + echo " ✓ Saved $count products to prodigi-catalog.json" +} + +# Load environment +if [ -f "$PROJECT_DIR/config/.env" ]; then + export $(cat "$PROJECT_DIR/config/.env" | grep -v '^#' | xargs) +fi + +case $PROVIDER in + printful) + sync_printful + ;; + prodigi) + sync_prodigi + ;; + all) + sync_printful + sync_prodigi + ;; + *) + echo "Unknown provider: $PROVIDER" + echo "Usage: $0 [printful|prodigi|all]" + exit 1 + ;; +esac + +echo "" +echo "Catalogs cached in: $CACHE_DIR" +echo "" +echo "View catalogs with:" +echo " jq '.result[] | {id, title}' $CACHE_DIR/printful-catalog.json" +echo " jq '.products[] | {sku, description}' $CACHE_DIR/prodigi-catalog.json" diff --git a/templates/printful/sticker-3x3.svg b/templates/printful/sticker-3x3.svg new file mode 100644 index 0000000..c130465 --- /dev/null +++ b/templates/printful/sticker-3x3.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + 3" × 3" STICKER (900×900 px) + + + + + + Bleed + + + Cut + + + Safe + + + + + + DESIGN + + + + diff --git a/templates/printful/tshirt-front-12x16.svg b/templates/printful/tshirt-front-12x16.svg new file mode 100644 index 0000000..0fdb03a --- /dev/null +++ b/templates/printful/tshirt-front-12x16.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + PRINTFUL T-SHIRT FRONT - 12" × 16" (3600×4800 px) + + + Delete this text layer before exporting + + + + + + + YOUR DESIGN HERE + + + + diff --git a/templates/prodigi/art-print-8x10.svg b/templates/prodigi/art-print-8x10.svg new file mode 100644 index 0000000..798948e --- /dev/null +++ b/templates/prodigi/art-print-8x10.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PRODIGI ART PRINT - 8" × 10" (2400×3000 px) + + + + + + Bleed + + + Trim + + + Safe zone + + + Rule of thirds + + + + + + YOUR ARTWORK + + + Replace this layer with your design + + + + diff --git a/templates/prodigi/vinyl-sticker-kiss-cut.svg b/templates/prodigi/vinyl-sticker-kiss-cut.svg new file mode 100644 index 0000000..49c23b1 --- /dev/null +++ b/templates/prodigi/vinyl-sticker-kiss-cut.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + PRODIGI VINYL STICKER - 4" × 4" (1200×1200 px) + + + + + + Bleed (extend design here) + + + Cut line + + + Safe zone (keep text here) + + + + + + YOUR DESIGN + + + Replace this layer + + + +