#!/usr/bin/env node /** * Seed a dev store's collections from the production membership snapshot. * * node --env-file=.env scripts/seed-dev-store.mjs # dry run * node --env-file=.env scripts/seed-dev-store.mjs --apply # write * * Why a script at all: the product CSV round-trips products and product * metafields, but it cannot carry collection membership — the `Collection` * column is import-only, takes one collection per product, and this catalogue * runs 1.6 memberships per product across 16 collections. * * Per collection in the snapshot it: * 1. skips the collection when the handle already exists in the destination * (and reports the destination's member count, so drift is visible); * 2. otherwise creates it with its members in one call, using the current * 2026-10 shape — `CollectionCreateInput.sources[].source.inclusion * .selections` on the `collection` argument. The `input`/`ruleSet` form is * deprecated, and so is `collectionAddProducts`; * 3. publishes it, because `collectionCreate` leaves a collection * unpublished and an unpublished collection is invisible to the * Storefront API the dev store exists to serve. * * It never deletes anything, never touches products, and re-runs are safe. * Products must already exist in the destination: run the CSV import first. * * Env: * SHOPIFY_DEV_STORE_DOMAIN e.g. staging-iw0gviik.myshopify.com * SHOPIFY_DEV_ADMIN_TOKEN Admin API access token (shpat_…) from a custom * app installed in the dev store. Scopes: * read_products, write_products, * read_publications, write_publications. * Production's app cannot be used — it is * Storefront-scoped only, with no read_products. * SHOPIFY_ADMIN_API_VERSION default 2026-10 * * Flags: * --apply write. Without it the run is a dry run. * --only=handle,handle limit to these collections * --publication= publish target (default: Online Store) * --snapshot= membership snapshot to seed from */ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const DOMAIN = process.env.SHOPIFY_DEV_STORE_DOMAIN ?? ""; const TOKEN = process.env.SHOPIFY_DEV_ADMIN_TOKEN ?? ""; const API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION ?? "2026-10"; const args = process.argv.slice(2); const flag = (name, fallback) => { const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`)); if (!hit) return fallback; return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : true; }; const APPLY = Boolean(flag("apply", false)); const ONLY = String(flag("only", "") ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); const PUBLICATION = String(flag("publication", "Online Store")); const SNAPSHOT = String( flag( "snapshot", join(root, ".scratch/shopify-headless-commerce/assets/dev-store-seed.json"), ), ); if (!DOMAIN || !TOKEN) { console.error( "Set SHOPIFY_DEV_STORE_DOMAIN and SHOPIFY_DEV_ADMIN_TOKEN — see the header of this file.", ); process.exit(2); } /* ── Admin API ─────────────────────────────────────────────── */ const gql = async (query, variables) => { const res = await fetch( `https://${DOMAIN}/admin/api/${API_VERSION}/graphql.json`, { method: "POST", headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": TOKEN, }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(20_000), }, ); const json = await res.json().catch(() => null); if (!res.ok || !json) { throw new Error(`Admin API HTTP ${res.status} ${res.statusText}`); } if (json.errors?.length) { throw new Error(json.errors.map((e) => e.message).join("; ")); } return json.data; }; const CREATE_COLLECTION = /* GraphQL */ ` mutation SeedCollection($collection: CollectionCreateInput!) { collectionCreate(collection: $collection) { collection { id handle } userErrors { field message } } } `; const PUBLISH = /* GraphQL */ ` mutation Publish($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { field message } } } `; /* ── the plan ──────────────────────────────────────────────── */ const snapshot = JSON.parse(readFileSync(SNAPSHOT, "utf8")); const plan = snapshot.collections.filter( (c) => !ONLY.length || ONLY.includes(c.handle), ); if (!plan.length) { console.error(`Nothing to seed: ${SNAPSHOT} has no matching collections.`); process.exit(2); } console.log( `${APPLY ? "Seeding" : "Dry run against"} ${DOMAIN} (Admin API ${API_VERSION})`, ); console.log( `snapshot: ${SNAPSHOT}\n ${snapshot.productCount} products, ` + `${snapshot.collectionCount} collections, ${snapshot.membershipTotal} memberships ` + `(taken ${snapshot.generatedAt})\n`, ); let products; let collections; let publications; try { products = await gql( `query { products(first: 250) { nodes { id handle } } }`, ); collections = await gql( `query { collections(first: 250) { nodes { id handle productsCount { count } } } }`, ); publications = await gql( `query { publications(first: 20) { nodes { id name } } }`, ); } catch (error) { console.error(`Could not read the destination store: ${error.message}`); console.error( "The custom app needs read_products and read_publications, and must be installed.", ); process.exit(2); } const productId = new Map(products.products.nodes.map((p) => [p.handle, p.id])); const existing = new Map( collections.collections.nodes.map((c) => [c.handle, c]), ); const pubs = publications.publications.nodes ?? []; const chosen = pubs.find( (p) => p.name.toLowerCase() === PUBLICATION.toLowerCase() || p.id === PUBLICATION, ); if (!chosen) { console.error( `No publication matches "${PUBLICATION}". Available: ${pubs.map((p) => p.name).join(", ") || "(none)"}`, ); process.exit(2); } console.log(`publish target: ${chosen.name} (${chosen.id})\n`); /* ── report and apply ──────────────────────────────────────── */ const tally = { created: 0, published: 0, existing: 0, empty: 0, errors: 0 }; for (const c of plan) { const missing = c.members.filter((h) => !productId.has(h)); const ids = c.members .filter((h) => productId.has(h)) .map((h) => productId.get(h)); const label = c.handle.padEnd(24); const note = `${String(c.members.length).padStart(3)} members`; if (missing.length) { console.log( `${label}${note} ${missing.length} not in the destination: ${missing.join(", ")}`, ); } const here = existing.get(c.handle); if (here) { tally.existing += 1; const count = here.productsCount?.count ?? "?"; const drift = count === c.members.length ? "" : " ← differs from the snapshot"; console.log(`${label}${note} exists with ${count} products${drift}`); continue; } if (!ids.length) { tally.empty += 1; console.log( `${label}${note} skipped: none of its products exist here yet`, ); continue; } console.log( `${label}${note} ${APPLY ? "creating" : "would create"} and publish to ${chosen.name}`, ); if (!APPLY) continue; const created = await gql(CREATE_COLLECTION, { collection: { title: c.title, handle: c.handle, sources: [ { source: { title: c.title, inclusion: { selections: ids.map((productId) => ({ productId })) }, }, }, ], }, }); const userErrors = created.collectionCreate?.userErrors ?? []; if (userErrors.length || !created.collectionCreate?.collection) { tally.errors += 1; console.error( `${label} create failed: ${userErrors.map((e) => `${e.field?.join(".")} ${e.message}`).join("; ") || "no collection returned"}`, ); continue; } tally.created += 1; const gid = created.collectionCreate.collection.id; const published = await gql(PUBLISH, { id: gid, input: [{ publicationId: chosen.id }], }); const pubErrors = published.publishablePublish?.userErrors ?? []; if (pubErrors.length) { tally.errors += 1; console.error( `${label} created but not published: ${pubErrors.map((e) => e.message).join("; ")}`, ); continue; } tally.published += 1; } console.log( `\n${APPLY ? "Done" : "Dry run complete"}: ` + `${tally.created} created, ${tally.published} published, ` + `${tally.existing} already present, ${tally.empty} skipped, ${tally.errors} errors.`, ); if (!APPLY) { console.log("Nothing was written. Re-run with --apply."); } process.exit(tally.errors ? 1 : 0);