@mrhenry/shopify-toolkit
v0.1.14
Published
Shopify theme tooling for downstream repos: canonical theme JSON formatting and a @shopify/cli range
Readme
@mrhenry/shopify-toolkit
The tools a Shopify theme repo needs on its own machine: the canonical form of
the theme JSON Shopify owns (owned theme JSON), a mr-shopify-toolkit
command that applies it, and a pinned
@shopify/cli.
It is the npm half of
mrhenry/shopify-toolkit, the
repository that also hosts the reusable PR workflows. The canonical JSON
definition lives here (src/theme-json.mjs) and the workflow action re-exports
it, so a merge performed in CI and a file formatted on a laptop end up with the
same bytes.
Install
npx @mrhenry/shopify-toolkit initRequires Node >= 22.12. @shopify/cli comes along as a dependency, as a ^
range rather than a pin, and the repository's own workflows install
@shopify/cli@latest. Nothing writes the CLI version down twice, so CI cannot
run a different CLI than a laptop does; test/toolkit-package.test.mjs fails if
a pin comes back.
mr-shopify-toolkit init
Sets up a repo that already holds a theme for the toolkit's PR workflows. It asks for the store's shop name first, then writes what the workflows read and call:
| Written | Contents |
| --- | --- |
| shopify.theme.toml | two tables: [environments.default] (the developer's: path, reconciliation-strategy, store, theme-editor-sync) and [environments.ci] (automation: path, store). store-password goes in both when one was given |
| package.json | this package as a devDependency, plus dev, format-json and shopify scripts |
| .github/workflows/theme-pr.yml | the caller template, with OWNER replaced |
| .github/workflows/theme-mirror.yml | the purchased-theme caller template, with OWNER replaced; written only when a purchased theme id is given |
| .github/workflows/preset-backup.yml | the preset-backup caller template, with OWNER replaced |
| .github/action-chain-policy.json | the ALLOW entry that lets the preset backup's self-update step mint its write token, merged into any existing policy |
| .github/dependabot.yml | monthly Dependabot presets for npm and GitHub Actions, grouped, with a 7-day cooldown and a limit of 10 open PRs |
| .gitignore | node_modules and .DS_Store, appended if missing |
| .editorconfig | 2-space indentation for theme files, tabs for .vscode/*.json |
| .vscode/settings.json | "files.associations": { "*.json": "jsonc" }, merged into existing settings, or left alone when the existing one is annotated (see below) |
| .vscode/extensions.json | "recommendations": ["shopify.theme-check-vscode"], appended to existing recommendations, or left alone when the existing file is annotated (see below) |
npx mr-shopify-toolkit init # asks for the shop name and storefront password
npx mr-shopify-toolkit init --shop my-store # non-interactive
npx mr-shopify-toolkit init path/to/theme # somewhere other than the cwdOptions: --shop <name>, --store-password <value>, --owner <owner>
(defaults to mrhenry), --path <theme path> (the path in the TOML, for a
theme that is not at the repo root), --workflow <file.yml>.
npm run dev — written as mr-shopify-toolkit logout-if-expired; shopify theme
dev — clears a dead theme session, then serves the theme locally against the
store in shopify.theme.toml, so a fresh repo does not have to remember the
command. These three scripts (dev, format-json, shopify) are the ones
init owns: they are written even when the repo already defines them, while
every other key in scripts is left alone.
The shop name may be given as my-store, my-store.myshopify.com or a store
URL; it is normalized to the .myshopify.com domain. It is the one thing
init cannot default, so it is asked for — with --shop for scripts, and from
stdin when that is a pipe rather than a terminal. The storefront password is
asked for next; it is optional, and Enter leaves the key out.
init does not write a theme key. To develop against a specific theme, add
theme = "<id>" to [environments.default] by hand — the table npm run dev
reads — and never to [environments.ci], which the PR workflows select. A
theme key in the selected environment is load-bearing: --development-context,
which is how every preview theme is pushed, is exclusive: ["theme"] in the
CLI, so it makes every theme push abort, and no flag rescues it. A live theme
also needs allow-live = true in [environments.default].
The password prompt is masked, and the value is never echoed back. Note that
store-password is the storefront password of a password-protected store — it
is not the password key, which is the Theme Access token the workflows
authenticate with and which must never be committed (it comes from the
THEME_ACCESS_TOKEN secret). init therefore prints a line saying it wrote a
committed password; use it only for a store whose storefront password you are
happy to see in git.
Nothing that already exists is overwritten: an existing shopify.theme.toml,
workflow, .github/dependabot.yml, .github/action-chain-policy.json,
.editorconfig, .vscode/settings.json or .vscode/extensions.json is kept
(and reported as kept), an existing package.json keeps its own fields, and a
second init changes nothing. The action-chain policy is the one merged file: an
entry for another chained workflow is preserved and the preset caller's ALLOW
entry is appended. What init cannot do -- the THEME_ACCESS_TOKEN secret and the
toolkit repo's Actions access -- is printed as the next steps.
Both .vscode files are JSONC, and rewriting one would drop the comments in it,
so a file that carries comments or trailing commas is reported instead of
rewritten, with the line to add by hand ("*.json": "jsonc" under
files.associations, or "shopify.theme-check-vscode" under recommendations).
A package.json that cannot be read at all is reported the same way: the rest of
the setup still happens and init still exits 0.
The recommended extension is Shopify's theme
check,
which surfaces the same offenses the Theme Check Report check run shows, in the
editor. init appends it to the recommendations a repo already has rather than
writing the array itself, so nothing the repo recommends is dropped or reordered.
mr-shopify-toolkit format-json
Rewrites every owned theme JSON file into the canonical form its admin editor writes: an auto-generated header, 2-space indentation, no comments, no trailing commas, one trailing newline. Not minified.
npx mr-shopify-toolkit format-json # the working directory is the theme root
npx mr-shopify-toolkit format-json path/to/themeOnly these files are touched, because they are the files the admin editors rewrite (Shopify's JSON with comments documentation):
| Path | Files | Header |
| --- | --- | --- |
| config/settings_data.json | the saved theme settings | theme editor |
| templates/**/*.json | JSON templates, e.g. templates/cart.json | theme editor |
| sections/**/*.json | section groups, e.g. sections/header-group.json | theme editor |
| locales/**/*.json | locale files, e.g. locales/cs.json | language editor |
Two headers exist because two editors write these files: the admin theme
editor owns config/, templates/ and sections/, the admin language
editor owns locales/, and each words the header accordingly. The command
picks the right one per path.
Everything else is author-owned and left byte-for-byte alone, including
config/settings_schema.json, config/markets.json and every .liquid file.
Input is read as JSONC, so a file that still carries comments or trailing commas
is formatted rather than rejected. Forward slashes are normalized to /: a
locale file pulled from a store may hold href=\"\/admin\", and the canonical
form holds href=\"/admin\". The value parses identically either way, and the
merge already compares those files by parsed value, so it is not treated as a
change.
Keys keep their position. A JavaScript object enumerates array-index keys
such as "404" before every string key, so a plain parse-and-reprint would move
a locale's templates.404 to the top of templates. The key order is therefore
read from the text itself, and only the values, indentation, escaping and header
are normalized.
A file that cannot be parsed at all is reported and skipped, and the command
still exits 1 at the end. Files that are already canonical are not rewritten,
so their mtime does not move.
Exit codes: 0 done — including a directory with no owned files to rewrite — 1
a file could not be parsed, 2 the arguments were not understood. The CLI
itself exits 0 for --help, 1 when no command is given, and 2 for an
unknown one.
mr-shopify-toolkit logout-if-expired
Clears a dead Shopify CLI theme session so the next shopify theme dev can
sign in again instead of failing on a stale token.
npx mr-shopify-toolkit logout-if-expired # silent unless it acts
npx mr-shopify-toolkit logout-if-expired --verbose # say what it foundThe Shopify CLI has no auth status — auth only has login and logout — so
the only way to tell a live session from a revoked or expired one is to use it.
This command probes with shopify theme list --json: read-only, no prompts, and
it uses the same OAuth theme session theme dev uses (pull/push use a
store auth session instead). The probe runs with stdin closed, its output
captured, and the CLI told it is non-interactive (CI=1, SHOPIFY_FLAG_NO_INPUT=1),
so it can never open a login prompt or print to the user. Without that, an absent
session makes theme list start the device flow — it opens the browser, prints
the confirmation code to the captured stderr, and polls until it times out,
stalling npm run dev before theme dev ever runs.
| The probe | What the command does |
| --- | --- |
| exits 0 | the session is valid: nothing, exit 0 |
| fails with an auth signature (Service is not valid for authentication, Invalid API key or access token, GraphQL Error (Code: 401), Token refresh failed) | runs shopify auth logout, prints one line, exit 0 |
| says authorization is required / no session | nothing, exit 0 — theme dev will ask for a login itself |
| fails for any other reason (network, missing CLI) | nothing — logging out on an unknown error would be a guess — reports the error, exit non-zero |
A scope error (missing … access scope) is reported, never logged out: the
token is valid, and a logout would throw it away without fixing anything.
The generated dev script runs this guard first, joined with ; rather than
&&, so a probe that fails for a reason that is not a dead session cannot stop
the dev server.
API
The formatter is built on the same module the workflows use, which is exported for downstream tooling that has to write these files itself:
import { autoGeneratedHeaderFor, isOwnedThemeJson, jsonKeyOrder, parseThemeJson, serializeThemeJson } from '@mrhenry/shopify-toolkit'
isOwnedThemeJson('templates/cart.json') // true
isOwnedThemeJson('config/settings_schema.json') // false
autoGeneratedHeaderFor('locales/cs.json') // the language editor's header
serializeThemeJson({ order: ['main'] }, 'templates/index.json') // header + 2-space JSON
// Rewriting a file you parsed yourself? Pass its key order to keep `"404"` put.
serializeThemeJson(value, rel, jsonKeyOrder(text))Development
Run all three from the repository root, which is where npm ci installs the
workspace; nothing is defined twice per package.
npm ci # from the repository root; installs the workspace too
npm test # node --test
npm run lint # eslint, zero warningsKeep the published package dependency-free apart from @shopify/cli: the
workflow action imports src/theme-json.mjs directly from this directory, so
anything it needs must be a Node built-in.
The templates/*.yml files are the caller workflows init writes
(theme-pr.yml always; theme-mirror.yml only with a purchased theme id;
preset-backup.yml always). Each body must stay identical to the
repository's matching examples/*.yml; test/toolkit-package.test.mjs fails
when one drifts.
