@shopify/hydrogen 2026.10.0-preview.2 → 2026.10.0-preview.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +32 -1
  2. package/dist/cli/index.mjs +464 -73
  3. package/dist/core/cart/actions.mjs +2 -0
  4. package/dist/core/cart/actions.mjs.map +1 -1
  5. package/dist/core/cart/cart.mjs +16 -6
  6. package/dist/core/cart/cart.mjs.map +1 -1
  7. package/dist/core/product/form.d.mts +21 -2
  8. package/dist/core/product/form.mjs +13 -0
  9. package/dist/core/product/form.mjs.map +1 -1
  10. package/dist/core/product/index.d.mts +1 -1
  11. package/dist/core/request-context.d.mts +6 -0
  12. package/dist/core/request-context.mjs +6 -2
  13. package/dist/core/request-context.mjs.map +1 -1
  14. package/dist/core/request-routing/handle-shopify-routes.mjs +2 -0
  15. package/dist/core/request-routing/handle-shopify-routes.mjs.map +1 -1
  16. package/dist/core/request-routing/interceptors/checkout.mjs +4 -1
  17. package/dist/core/request-routing/interceptors/checkout.mjs.map +1 -1
  18. package/dist/core/request-routing/interceptors/proxy.mjs +19 -8
  19. package/dist/core/request-routing/interceptors/proxy.mjs.map +1 -1
  20. package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
  21. package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
  22. package/dist/core/shopify-scripts/analytics.mjs +1 -1
  23. package/dist/core/shopify-scripts/analytics.mjs.map +1 -1
  24. package/dist/core/shopify-scripts/constants.mjs +3 -1
  25. package/dist/core/shopify-scripts/constants.mjs.map +1 -1
  26. package/dist/core/shopify-scripts/deprecated-cookies.mjs +2 -1
  27. package/dist/core/shopify-scripts/deprecated-cookies.mjs.map +1 -1
  28. package/dist/core/shopify-scripts/global.mjs +5 -4
  29. package/dist/core/shopify-scripts/global.mjs.map +1 -1
  30. package/dist/core/shopify-scripts/index.d.mts +1 -0
  31. package/dist/core/shopify-scripts/index.mjs +14 -12
  32. package/dist/core/shopify-scripts/index.mjs.map +1 -1
  33. package/dist/core/shopify-scripts/types.d.mts +5 -0
  34. package/dist/core/url.mjs +2 -1
  35. package/dist/core/url.mjs.map +1 -1
  36. package/dist/customer-account/client.mjs +1 -1
  37. package/dist/customer-account/session.mjs +1 -1
  38. package/dist/development/core/cart/actions.mjs +2 -0
  39. package/dist/development/core/cart/actions.mjs.map +1 -1
  40. package/dist/development/core/cart/cart.mjs +16 -6
  41. package/dist/development/core/cart/cart.mjs.map +1 -1
  42. package/dist/development/core/cart/queries.d.mts +57 -57
  43. package/dist/development/core/predictive-search/queries.d.mts +2 -2
  44. package/dist/development/core/product/form.d.mts +21 -2
  45. package/dist/development/core/product/form.mjs +13 -0
  46. package/dist/development/core/product/form.mjs.map +1 -1
  47. package/dist/development/core/product/index.d.mts +1 -1
  48. package/dist/development/core/request-context.d.mts +6 -0
  49. package/dist/development/core/request-context.mjs +6 -2
  50. package/dist/development/core/request-context.mjs.map +1 -1
  51. package/dist/development/core/request-routing/handle-shopify-routes.mjs +2 -0
  52. package/dist/development/core/request-routing/handle-shopify-routes.mjs.map +1 -1
  53. package/dist/development/core/request-routing/interceptors/checkout.mjs +4 -1
  54. package/dist/development/core/request-routing/interceptors/checkout.mjs.map +1 -1
  55. package/dist/development/core/request-routing/interceptors/proxy.mjs +19 -8
  56. package/dist/development/core/request-routing/interceptors/proxy.mjs.map +1 -1
  57. package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
  58. package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
  59. package/dist/development/core/shopify-scripts/analytics.mjs +1 -1
  60. package/dist/development/core/shopify-scripts/analytics.mjs.map +1 -1
  61. package/dist/development/core/shopify-scripts/constants.mjs +3 -1
  62. package/dist/development/core/shopify-scripts/constants.mjs.map +1 -1
  63. package/dist/development/core/shopify-scripts/deprecated-cookies.mjs +2 -1
  64. package/dist/development/core/shopify-scripts/deprecated-cookies.mjs.map +1 -1
  65. package/dist/development/core/shopify-scripts/global.mjs +5 -4
  66. package/dist/development/core/shopify-scripts/global.mjs.map +1 -1
  67. package/dist/development/core/shopify-scripts/index.d.mts +1 -0
  68. package/dist/development/core/shopify-scripts/index.mjs +14 -12
  69. package/dist/development/core/shopify-scripts/index.mjs.map +1 -1
  70. package/dist/development/core/shopify-scripts/types.d.mts +5 -0
  71. package/dist/development/core/url.mjs +2 -1
  72. package/dist/development/core/url.mjs.map +1 -1
  73. package/dist/development/globals.d.mts +4 -1
  74. package/dist/development/react/index.d.mts +2 -2
  75. package/dist/development/vue/shopify-scripts.d.mts +9 -0
  76. package/dist/development/vue/shopify-scripts.mjs +5 -0
  77. package/dist/development/vue/shopify-scripts.mjs.map +1 -1
  78. package/dist/globals.d.mts +4 -1
  79. package/dist/react/index.d.mts +2 -2
  80. package/dist/vue/shopify-scripts.d.mts +10 -1
  81. package/dist/vue/shopify-scripts.mjs +5 -0
  82. package/dist/vue/shopify-scripts.mjs.map +1 -1
  83. package/package.json +1 -1
  84. package/skills/hydrogen-cart-metafields/SKILL.md +133 -0
  85. package/skills/hydrogen-cart-ui/SKILL.md +2 -1
  86. package/skills/hydrogen-setup/steps/11-verify.md +1 -0
  87. package/skills/hydrogen-setup/steps/2-scaffold.md +21 -0
  88. package/skills/hydrogen-storefront-client/SKILL.md +4 -0
  89. package/skills/hydrogen-variant-form/SKILL.md +10 -4
  90. package/dist/core/shopify-scripts/consent.mjs +0 -17
  91. package/dist/core/shopify-scripts/consent.mjs.map +0 -1
  92. package/dist/development/core/shopify-scripts/consent.mjs +0 -17
  93. package/dist/development/core/shopify-scripts/consent.mjs.map +0 -1
package/README.md CHANGED
@@ -10,7 +10,38 @@ Run the setup CLI in a storefront project.
10
10
  npx @shopify/hydrogen setup
11
11
  ```
12
12
 
13
- The setup command installs `@shopify/hydrogen` into the local project using the detected package manager, then copies the packaged skills into local agent skill directories.
13
+ The setup command installs `@shopify/hydrogen` into the local project using the detected package manager, then syncs the packaged skills into both `.claude/skills` (read by Claude Code) and `.agents/skills` (read by Codex, Cursor, and OpenCode). Both are always written so the project never has to declare which agents it uses.
14
+
15
+ ## Keeping skills in sync
16
+
17
+ Skills describe the API of the installed Hydrogen version, so resync them after every upgrade:
18
+
19
+ ```bash
20
+ npx @shopify/hydrogen skills sync
21
+ ```
22
+
23
+ Each synced `SKILL.md` records the package version and a content hash in its frontmatter `metadata`. On every run, per skill:
24
+
25
+ - unchanged content and version: left untouched
26
+ - shipped skill you have not edited: overwritten with the installed version
27
+ - shipped skill missing locally: added
28
+ - previously synced skill the installed version no longer ships: removed; if you edited it, you are asked first (kept with a warning when there is no terminal to ask, such as CI)
29
+ - skill you have edited locally: kept, and reported only when a newer version is waiting
30
+ - directory Hydrogen did not create that collides with a shipped skill name: the sync stops before writing anything
31
+
32
+ `--force` discards local state in favour of the package: it overwrites edited and colliding skills and removes edited stale ones.
33
+
34
+ Gate CI on the same comparison. The command exits non-zero when a sync would change anything, or when skills were never synced:
35
+
36
+ ```bash
37
+ npx @shopify/hydrogen skills check
38
+ ```
39
+
40
+ For dev scripts, `--mode=warn` prints the same message and exits zero so the next command still runs, and prints nothing at all when skills are current:
41
+
42
+ ```json
43
+ { "scripts": { "dev": "hydrogen skills check --mode=warn && vite dev" } }
44
+ ```
14
45
 
15
46
  ## GraphQL Tooling
16
47
 
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
- import { dirname, isAbsolute, join, parse, resolve } from "node:path";
7
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
8
9
  import { spawn } from "node:child_process";
9
10
  import { createHash, randomUUID } from "node:crypto";
10
11
  import { parseArgs } from "node:util";
@@ -444,14 +445,452 @@ function isObjectRecord(value) {
444
445
  }
445
446
 
446
447
  //#endregion
447
- //#region src/cli/setup.ts
448
- const PACKAGE_NAME = "@shopify/hydrogen";
449
- const PACKAGE_INSTALL_SPEC = `${PACKAGE_NAME}@preview`;
448
+ //#region src/cli/skills.ts
449
+ const PACKAGE_NAME$1 = "@shopify/hydrogen";
450
450
  const PACKAGE_ROOT_FROM_CLI_MODULE = "../../";
451
451
  const SKILLS_DIRECTORY_NAME = "skills";
452
+ const SKILL_FILE_NAME = "SKILL.md";
452
453
  const CLAUDE_DIRECTORY_NAME = ".claude";
453
454
  const AGENTS_DIRECTORY_NAME = ".agents";
454
- const NODE_MODULES_DIRECTORY_NAME = "node_modules";
455
+ const PACKAGE_JSON_FILE_NAME$1 = "package.json";
456
+ const HASH_ALGORITHM = "sha256";
457
+ const STAGING_SUFFIX = ".hydrogen-sync";
458
+ const FRONTMATTER_DELIMITER = "---";
459
+ const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---(\n|$)/;
460
+ const IGNORED_FILE_NAMES = new Set([
461
+ ".DS_Store",
462
+ "Thumbs.db",
463
+ "desktop.ini"
464
+ ]);
465
+ /**
466
+ * The exact block `renderMetadataBlock` writes. Reading, verifying, and
467
+ * stripping all go through this one pattern so writer and reader cannot drift.
468
+ */
469
+ const METADATA_BLOCK_PATTERN = new RegExp(`^metadata:\\n {2}source: "${PACKAGE_NAME$1}"\\n {2}version: "([^"\\n]*)"\\n {2}hash: "([^"\\n]*)"\\n`, "m");
470
+ /**
471
+ * Hashing strips every occurrence, not just the first: a skill whose body
472
+ * documents the block would otherwise hash differently before and after
473
+ * injection and read as locally modified forever.
474
+ */
475
+ const METADATA_BLOCK_GLOBAL_PATTERN = new RegExp(METADATA_BLOCK_PATTERN.source, "gm");
476
+ function assertDirectory(directoryPath, message) {
477
+ if (!existsSync(directoryPath) || !statSync(directoryPath).isDirectory()) throw new Error(message);
478
+ }
479
+ function assertDirectoryIfPresent(directoryPath, label) {
480
+ if (existsSync(directoryPath)) assertDirectory(directoryPath, `${label} exists but is not a directory.`);
481
+ }
482
+ function getPackageRoot() {
483
+ return fileURLToPath(new URL(PACKAGE_ROOT_FROM_CLI_MODULE, import.meta.url));
484
+ }
485
+ /**
486
+ * Resolves the Hydrogen the app actually depends on, walking up parent
487
+ * node_modules the way Node itself would. This finds hoisted installs in
488
+ * monorepos, which a plain `<appRoot>/node_modules` lookup misses.
489
+ */
490
+ function getInstalledPackageRoot(appRoot) {
491
+ const require = createRequire(join(appRoot, PACKAGE_JSON_FILE_NAME$1));
492
+ try {
493
+ return dirname(require.resolve(`${PACKAGE_NAME$1}/${PACKAGE_JSON_FILE_NAME$1}`));
494
+ } catch {
495
+ return;
496
+ }
497
+ }
498
+ function readPackageVersion(packageRoot) {
499
+ const packageJsonPath = join(packageRoot, PACKAGE_JSON_FILE_NAME$1);
500
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
501
+ if (!isObjectRecord(parsed) || typeof parsed.version !== "string") throw new Error(`${packageJsonPath} must declare a string version.`);
502
+ return parsed.version;
503
+ }
504
+ /**
505
+ * Every harness dir is written unconditionally: Claude Code reads `.claude/skills`,
506
+ * while Codex, Cursor, and OpenCode read `.agents/skills`. Writing both means no
507
+ * project has to declare which agents it uses.
508
+ */
509
+ function getSkillsDestinationRoots(appRoot) {
510
+ return [CLAUDE_DIRECTORY_NAME, AGENTS_DIRECTORY_NAME].map((harnessDirectory) => {
511
+ const skillsDirectory = join(harnessDirectory, SKILLS_DIRECTORY_NAME);
512
+ assertDirectoryIfPresent(join(appRoot, harnessDirectory), harnessDirectory);
513
+ assertDirectoryIfPresent(join(appRoot, skillsDirectory), skillsDirectory);
514
+ return join(appRoot, skillsDirectory);
515
+ });
516
+ }
517
+ function listDirectoryNames(root) {
518
+ if (!existsSync(root)) return [];
519
+ return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
520
+ }
521
+ function listFilesRecursively(root) {
522
+ const files = [];
523
+ const visit = (directory) => {
524
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
525
+ if (IGNORED_FILE_NAMES.has(entry.name)) continue;
526
+ const entryPath = join(directory, entry.name);
527
+ if (entry.isDirectory()) visit(entryPath);
528
+ else files.push(entryPath);
529
+ }
530
+ };
531
+ visit(root);
532
+ return files.map((filePath) => relative(root, filePath).split(sep).join("/")).toSorted((left, right) => left < right ? -1 : left > right ? 1 : 0);
533
+ }
534
+ function readNormalizedText(filePath) {
535
+ return readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
536
+ }
537
+ function renderMetadataBlock(metadata) {
538
+ return [
539
+ "metadata:",
540
+ ` source: "${PACKAGE_NAME$1}"`,
541
+ ` version: "${metadata.version}"`,
542
+ ` hash: "${metadata.hash}"`,
543
+ ""
544
+ ].join("\n");
545
+ }
546
+ /**
547
+ * Hashes every file in a skill directory. Line endings are normalized so a skill
548
+ * checked out through git autocrlf still verifies against the hash written on
549
+ * another platform. The injected metadata block is stripped from SKILL.md so a
550
+ * synced copy hashes to the same value as its shipped source.
551
+ */
552
+ function hashSkillDirectory(skillRoot) {
553
+ const hash = createHash(HASH_ALGORITHM);
554
+ for (const relativePath of listFilesRecursively(skillRoot)) {
555
+ let content = readNormalizedText(join(skillRoot, relativePath));
556
+ if (relativePath === SKILL_FILE_NAME) content = content.replaceAll(METADATA_BLOCK_GLOBAL_PATTERN, "");
557
+ hash.update(relativePath);
558
+ hash.update("\0");
559
+ hash.update(content);
560
+ hash.update("\0");
561
+ }
562
+ return `${HASH_ALGORITHM}:${hash.digest("hex")}`;
563
+ }
564
+ function readShippedSkill(sourceSkillsRoot, skillName, version) {
565
+ const sourceRoot = join(sourceSkillsRoot, skillName);
566
+ const skillFilePath = join(sourceRoot, SKILL_FILE_NAME);
567
+ if (!existsSync(skillFilePath)) throw new Error(`${skillFilePath} is missing.`);
568
+ const content = readNormalizedText(skillFilePath);
569
+ const frontmatterMatch = FRONTMATTER_PATTERN.exec(content);
570
+ if (!frontmatterMatch) throw new Error(`${skillFilePath} has no frontmatter to record Hydrogen metadata in.`);
571
+ const [fullMatch, frontmatter, terminator] = frontmatterMatch;
572
+ if (/^metadata:/m.test(frontmatter)) throw new Error(`${skillFilePath} already declares frontmatter metadata.`);
573
+ const metadata = {
574
+ version,
575
+ hash: hashSkillDirectory(sourceRoot)
576
+ };
577
+ return {
578
+ skillName,
579
+ sourceRoot,
580
+ metadata,
581
+ skillFile: [
582
+ FRONTMATTER_DELIMITER,
583
+ frontmatter,
584
+ renderMetadataBlock(metadata) + FRONTMATTER_DELIMITER
585
+ ].join("\n") + terminator + content.slice(fullMatch.length)
586
+ };
587
+ }
588
+ function readDestinationState(skillRoot) {
589
+ if (!existsSync(skillRoot)) return { kind: "absent" };
590
+ const skillFilePath = join(skillRoot, SKILL_FILE_NAME);
591
+ if (!existsSync(skillFilePath)) return { kind: "partial" };
592
+ const frontmatter = FRONTMATTER_PATTERN.exec(readNormalizedText(skillFilePath))?.[1];
593
+ const metadataMatch = frontmatter === void 0 ? null : METADATA_BLOCK_PATTERN.exec(frontmatter + "\n");
594
+ if (!metadataMatch) return { kind: "unmanaged" };
595
+ const metadata = {
596
+ version: metadataMatch[1] ?? "",
597
+ hash: metadataMatch[2] ?? ""
598
+ };
599
+ return {
600
+ kind: "managed",
601
+ metadata,
602
+ modified: hashSkillDirectory(skillRoot) !== metadata.hash
603
+ };
604
+ }
605
+ /** Returns undefined when the destination is user content that must not be touched. */
606
+ function decideShippedAction(state, shipped, force) {
607
+ switch (state.kind) {
608
+ case "absent": return "add";
609
+ case "partial": return "update";
610
+ case "unmanaged": return force ? "update" : void 0;
611
+ case "managed": {
612
+ const current = state.metadata.hash === shipped.hash && state.metadata.version === shipped.version;
613
+ if (!state.modified) return current ? "unchanged" : "update";
614
+ if (force) return "update";
615
+ return current ? "unchanged" : "skip";
616
+ }
617
+ }
618
+ }
619
+ /** Returns undefined when the stale directory is user content that must not be touched. */
620
+ function decideStaleAction(state, force) {
621
+ if (state.kind !== "managed") return void 0;
622
+ return !state.modified || force ? "remove" : "orphan";
623
+ }
624
+ function planDestination(destinationRoot, shippedSkills, force) {
625
+ const planned = [];
626
+ const conflicts = [];
627
+ const orphans = [];
628
+ for (const [skillName, shipped] of shippedSkills) {
629
+ const skillRoot = join(destinationRoot, skillName);
630
+ const action = decideShippedAction(readDestinationState(skillRoot), shipped.metadata, force);
631
+ if (!action) conflicts.push(skillRoot);
632
+ else if (action === "add" || action === "update") planned.push({
633
+ action,
634
+ skillName,
635
+ destinationRoot,
636
+ shipped
637
+ });
638
+ else planned.push({
639
+ action,
640
+ skillName,
641
+ destinationRoot
642
+ });
643
+ }
644
+ for (const skillName of listDirectoryNames(destinationRoot)) {
645
+ if (shippedSkills.has(skillName)) continue;
646
+ if (skillName.endsWith(STAGING_SUFFIX)) {
647
+ planned.push({
648
+ action: "remove",
649
+ skillName,
650
+ destinationRoot
651
+ });
652
+ continue;
653
+ }
654
+ const action = decideStaleAction(readDestinationState(join(destinationRoot, skillName)), force);
655
+ if (action === "orphan") orphans.push(skillName);
656
+ else if (action) planned.push({
657
+ action,
658
+ skillName,
659
+ destinationRoot
660
+ });
661
+ }
662
+ return {
663
+ destinationRoot,
664
+ planned,
665
+ conflicts,
666
+ orphans
667
+ };
668
+ }
669
+ /**
670
+ * Asks once per skill name rather than once per harness directory, since both
671
+ * copies came from the same shipped skill and the answer applies to both.
672
+ */
673
+ async function planOrphans(plans, version, confirm) {
674
+ const orphanNames = new Set(plans.flatMap((plan) => plan.orphans));
675
+ for (const skillName of orphanNames) {
676
+ const action = await confirm(`Skill ${skillName} was edited locally and Hydrogen ${version} no longer ships it. Remove it?`) ? "remove" : "keep";
677
+ for (const plan of plans) if (plan.orphans.includes(skillName)) plan.planned.push({
678
+ action,
679
+ skillName,
680
+ destinationRoot: plan.destinationRoot
681
+ });
682
+ }
683
+ }
684
+ function canPromptInTerminal() {
685
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
686
+ }
687
+ async function confirmInTerminal(question) {
688
+ const readline = createInterface({
689
+ input: process.stdin,
690
+ output: process.stdout
691
+ });
692
+ try {
693
+ const answer = (await readline.question(`${question} [Y/n] `)).trim().toLowerCase();
694
+ return answer === "" || answer === "y" || answer === "yes";
695
+ } finally {
696
+ readline.close();
697
+ }
698
+ }
699
+ /** Never removes user edits without an answer, so no TTY means keep. */
700
+ function confirmByDefault(question) {
701
+ return canPromptInTerminal() ? confirmInTerminal(question) : Promise.resolve(false);
702
+ }
703
+ /**
704
+ * Stages the full copy next to the destination and swaps it in last, so an
705
+ * interrupted run never leaves a half-written skill that a later sync would
706
+ * mistake for user content.
707
+ */
708
+ function installSkill(destinationRoot, skillRoot, shipped) {
709
+ const stagingRoot = skillRoot + STAGING_SUFFIX;
710
+ mkdirSync(destinationRoot, { recursive: true });
711
+ rmSync(stagingRoot, {
712
+ recursive: true,
713
+ force: true
714
+ });
715
+ cpSync(shipped.sourceRoot, stagingRoot, { recursive: true });
716
+ writeFileSync(join(stagingRoot, SKILL_FILE_NAME), shipped.skillFile);
717
+ rmSync(skillRoot, {
718
+ recursive: true,
719
+ force: true
720
+ });
721
+ renameSync(stagingRoot, skillRoot);
722
+ }
723
+ function executePlan(destinationRoot, planned) {
724
+ const result = {
725
+ root: destinationRoot,
726
+ added: 0,
727
+ updated: 0,
728
+ unchanged: 0,
729
+ removed: 0,
730
+ skipped: [],
731
+ kept: []
732
+ };
733
+ for (const skill of planned) {
734
+ const skillRoot = join(destinationRoot, skill.skillName);
735
+ switch (skill.action) {
736
+ case "skip":
737
+ result.skipped.push(skillRoot);
738
+ break;
739
+ case "keep":
740
+ result.kept.push(skillRoot);
741
+ break;
742
+ case "unchanged":
743
+ result.unchanged += 1;
744
+ break;
745
+ case "remove":
746
+ rmSync(skillRoot, {
747
+ recursive: true,
748
+ force: true
749
+ });
750
+ result.removed += 1;
751
+ break;
752
+ case "add":
753
+ installSkill(destinationRoot, skillRoot, skill.shipped);
754
+ result.added += 1;
755
+ break;
756
+ case "update":
757
+ installSkill(destinationRoot, skillRoot, skill.shipped);
758
+ result.updated += 1;
759
+ break;
760
+ }
761
+ }
762
+ return result;
763
+ }
764
+ function parseSkillsSyncArgs(args) {
765
+ const { values } = parseArgs({
766
+ args,
767
+ options: { force: {
768
+ type: "boolean",
769
+ default: false
770
+ } },
771
+ strict: true
772
+ });
773
+ return { force: values.force };
774
+ }
775
+ function planSkillsSync(appRoot, packageRoot, force) {
776
+ const resolvedPackageRoot = packageRoot ?? getInstalledPackageRoot(appRoot) ?? getPackageRoot();
777
+ const sourceSkillsRoot = join(resolvedPackageRoot, SKILLS_DIRECTORY_NAME);
778
+ assertDirectory(sourceSkillsRoot, `No packaged skills found at ${sourceSkillsRoot}.`);
779
+ const version = readPackageVersion(resolvedPackageRoot);
780
+ const shippedSkills = new Map(listDirectoryNames(sourceSkillsRoot).map((skillName) => [skillName, readShippedSkill(sourceSkillsRoot, skillName, version)]));
781
+ return {
782
+ version,
783
+ destinations: getSkillsDestinationRoots(appRoot).map((destinationRoot) => planDestination(destinationRoot, shippedSkills, force))
784
+ };
785
+ }
786
+ /** Reports what a sync would change without touching the filesystem. */
787
+ function getSkillsSyncStatus(options = {}) {
788
+ const plan = planSkillsSync(options.cwd ?? process.cwd(), options.packageRoot, false);
789
+ const names = {
790
+ add: /* @__PURE__ */ new Set(),
791
+ update: /* @__PURE__ */ new Set(),
792
+ remove: /* @__PURE__ */ new Set(),
793
+ modified: /* @__PURE__ */ new Set()
794
+ };
795
+ for (const destination of plan.destinations) {
796
+ for (const skill of destination.planned) if (skill.action === "add" || skill.action === "update" || skill.action === "remove") names[skill.action].add(skill.skillName);
797
+ else if (skill.action === "skip") names.modified.add(skill.skillName);
798
+ for (const skillName of destination.orphans) names.modified.add(skillName);
799
+ }
800
+ return {
801
+ version: plan.version,
802
+ pending: {
803
+ add: names.add.size,
804
+ update: names.update.size,
805
+ remove: names.remove.size,
806
+ modified: names.modified.size
807
+ },
808
+ conflicts: plan.destinations.flatMap((destination) => destination.conflicts)
809
+ };
810
+ }
811
+ const SYNC_COMMAND = "npx @shopify/hydrogen skills sync";
812
+ /** A plain sync clears everything except local edits, which only `--force` resets. */
813
+ function describeNextStep(pending) {
814
+ const syncClears = pending.add + pending.update + pending.remove > 0;
815
+ if (pending.modified === 0) return `Run \`${SYNC_COMMAND}\`.`;
816
+ if (!syncClears) return `Run \`${SYNC_COMMAND} --force\` to reset them.`;
817
+ return `Run \`${SYNC_COMMAND}\`, or \`${SYNC_COMMAND} --force\` to also reset the locally modified ones.`;
818
+ }
819
+ /** Explains why a status is not up to date, or returns undefined when nothing needs doing. */
820
+ function describeSkillsSyncStatus(status) {
821
+ if (status.conflicts.length > 0) return `Hydrogen skills cannot be synced: ${status.conflicts.join(", ")} were not created by Hydrogen. Remove them or run \`${SYNC_COMMAND} --force\`.`;
822
+ const { pending } = status;
823
+ const parts = [
824
+ pending.update > 0 && `${pending.update} to update`,
825
+ pending.add > 0 && `${pending.add} new`,
826
+ pending.remove > 0 && `${pending.remove} removed upstream`,
827
+ pending.modified > 0 && `${pending.modified} locally modified`
828
+ ].filter((part) => typeof part === "string");
829
+ if (parts.length === 0) return void 0;
830
+ return `Hydrogen skills are out of date with @shopify/hydrogen ${status.version} (${parts.join(", ")}). ${describeNextStep(pending)}`;
831
+ }
832
+ const CHECK_MODES = ["error", "warn"];
833
+ function isCheckSkillsMode(value) {
834
+ return CHECK_MODES.some((mode) => mode === value);
835
+ }
836
+ function parseSkillsCheckArgs(args) {
837
+ const { values } = parseArgs({
838
+ args,
839
+ options: { mode: {
840
+ type: "string",
841
+ default: "error"
842
+ } },
843
+ strict: true
844
+ });
845
+ if (!isCheckSkillsMode(values.mode)) throw new Error(`Unknown --mode '${values.mode}'. Expected one of: ${CHECK_MODES.join(", ")}.`);
846
+ return { mode: values.mode };
847
+ }
848
+ /**
849
+ * Reports whether the synced skills match the installed package without writing.
850
+ * A project with no synced skills counts as drift; running this command is the opt-in.
851
+ */
852
+ function checkSkills(options = {}) {
853
+ const mode = options.mode ?? "error";
854
+ const log = options.log ?? console.log;
855
+ const warn = options.warn ?? console.warn;
856
+ const status = getSkillsSyncStatus(options);
857
+ const problem = describeSkillsSyncStatus(status);
858
+ if (problem) {
859
+ if (mode === "error") throw new Error(problem);
860
+ warn(problem);
861
+ return;
862
+ }
863
+ if (mode === "error") log(`Hydrogen skills are up to date with @shopify/hydrogen ${status.version}.`);
864
+ }
865
+ async function syncSkills(options = {}) {
866
+ const log = options.log ?? console.log;
867
+ const confirm = options.confirm ?? confirmByDefault;
868
+ const plan = planSkillsSync(options.cwd ?? process.cwd(), options.packageRoot, options.force ?? false);
869
+ const conflicts = plan.destinations.flatMap((destination) => destination.conflicts);
870
+ if (conflicts.length > 0) throw new Error(`Skill directories exist that Hydrogen did not create: ${conflicts.join(", ")}. Remove them or rerun with --force to overwrite.`);
871
+ await planOrphans(plan.destinations, plan.version, confirm);
872
+ const roots = plan.destinations.map((destination) => executePlan(destination.destinationRoot, destination.planned));
873
+ for (const root of roots) log(`Synced Hydrogen ${plan.version} skills to ${root.root}: ${root.added} added, ${root.updated} updated, ${root.unchanged} unchanged, ${root.removed} removed.`);
874
+ const skipped = roots.flatMap((root) => root.skipped);
875
+ if (skipped.length > 0) log(`Skipped ${skipped.length} locally modified skill(s): ${skipped.join(", ")}. Rerun with --force to overwrite.`);
876
+ const kept = roots.flatMap((root) => root.kept);
877
+ if (kept.length > 0) log([
878
+ "",
879
+ `WARNING: Hydrogen ${plan.version} no longer ships these skills, but they were edited locally so they were kept:`,
880
+ ...kept.map((skillRoot) => ` - ${skillRoot}`),
881
+ "Delete them yourself, or rerun with --force to remove them.",
882
+ ""
883
+ ].join("\n"));
884
+ return {
885
+ version: plan.version,
886
+ roots
887
+ };
888
+ }
889
+
890
+ //#endregion
891
+ //#region src/cli/setup.ts
892
+ const PACKAGE_NAME = "@shopify/hydrogen";
893
+ const PACKAGE_INSTALL_SPEC = `${PACKAGE_NAME}@preview`;
455
894
  const PACKAGE_JSON_FILE_NAME = "package.json";
456
895
  const SUCCESS_EXIT_CODE = 0;
457
896
  const PACKAGE_MANAGER_LOCKFILES = [
@@ -506,70 +945,6 @@ function hasHydrogenDependency(packageJson) {
506
945
  async function installHydrogen(appRoot, packageManager, runCommand) {
507
946
  await runCommand(packageManager, [...INSTALL_ARGS[packageManager]], { cwd: appRoot });
508
947
  }
509
- function assertDirectory(directoryPath, message) {
510
- if (!existsSync(directoryPath) || !statSync(directoryPath).isDirectory()) throw new Error(message);
511
- }
512
- function getSkillsDestinationRoots(appRoot) {
513
- const destinationRoots = [];
514
- const claudeDirectory = join(appRoot, CLAUDE_DIRECTORY_NAME);
515
- if (existsSync(claudeDirectory)) {
516
- assertDirectory(claudeDirectory, `${CLAUDE_DIRECTORY_NAME} exists but is not a directory.`);
517
- destinationRoots.push(join(claudeDirectory, SKILLS_DIRECTORY_NAME));
518
- }
519
- const agentsDirectory = join(appRoot, AGENTS_DIRECTORY_NAME);
520
- if (existsSync(agentsDirectory)) {
521
- assertDirectory(agentsDirectory, `${AGENTS_DIRECTORY_NAME} exists but is not a directory.`);
522
- destinationRoots.push(join(agentsDirectory, SKILLS_DIRECTORY_NAME));
523
- }
524
- return destinationRoots.length > 0 ? destinationRoots : [join(appRoot, AGENTS_DIRECTORY_NAME, SKILLS_DIRECTORY_NAME)];
525
- }
526
- function getSkillNames(sourceSkillsRoot) {
527
- assertDirectory(sourceSkillsRoot, `No packaged skills found at ${sourceSkillsRoot}.`);
528
- return readdirSync(sourceSkillsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
529
- }
530
- function findConflictingSkills(destinationRoot, skillNames) {
531
- return skillNames.filter((skillName) => existsSync(join(destinationRoot, skillName, "SKILL.md")));
532
- }
533
- function removeIncompleteSkills(destinationRoot, skillNames) {
534
- for (const skillName of skillNames) {
535
- const destinationPath = join(destinationRoot, skillName);
536
- if (!existsSync(destinationPath)) continue;
537
- if (existsSync(join(destinationPath, "SKILL.md"))) continue;
538
- rmSync(destinationPath, {
539
- recursive: true,
540
- force: true
541
- });
542
- }
543
- }
544
- function copySkills(packageRoot, appRoot) {
545
- const sourceSkillsRoot = join(packageRoot, SKILLS_DIRECTORY_NAME);
546
- const destinationRoots = getSkillsDestinationRoots(appRoot);
547
- const skillNames = getSkillNames(sourceSkillsRoot);
548
- const conflictingSkills = destinationRoots.flatMap((destinationRoot) => findConflictingSkills(destinationRoot, skillNames).map((skillName) => join(destinationRoot, skillName)));
549
- if (conflictingSkills.length > 0) throw new Error(`Skill directories already exist: ${conflictingSkills.join(", ")}. Remove them and rerun setup.`);
550
- for (const destinationRoot of destinationRoots) {
551
- removeIncompleteSkills(destinationRoot, skillNames);
552
- mkdirSync(destinationRoot, { recursive: true });
553
- for (const skillName of skillNames) cpSync(join(sourceSkillsRoot, skillName), join(destinationRoot, skillName), {
554
- recursive: true,
555
- errorOnExist: true,
556
- force: false
557
- });
558
- }
559
- return {
560
- destinationRoots,
561
- copiedCount: skillNames.length * destinationRoots.length
562
- };
563
- }
564
- function getPackageRoot() {
565
- return fileURLToPath(new URL(PACKAGE_ROOT_FROM_CLI_MODULE, import.meta.url));
566
- }
567
- function getLocalPackageRoot(appRoot) {
568
- const localPackageRoot = join(appRoot, NODE_MODULES_DIRECTORY_NAME, PACKAGE_NAME);
569
- if (!existsSync(localPackageRoot)) return void 0;
570
- assertDirectory(localPackageRoot, `${localPackageRoot} exists but is not a directory.`);
571
- return realpathSync(localPackageRoot);
572
- }
573
948
  function spawnRunCommand(command, args, options) {
574
949
  return new Promise((resolve, reject) => {
575
950
  const child = spawn(command, args, {
@@ -597,8 +972,12 @@ async function setupHydrogen(options = {}) {
597
972
  log(`Installing ${PACKAGE_NAME} with ${packageManager}...`);
598
973
  await installHydrogen(appRoot, packageManager, runCommand);
599
974
  }
600
- const result = copySkills(options.packageRoot ?? getLocalPackageRoot(appRoot) ?? getPackageRoot(), appRoot);
601
- log(`Copied ${result.copiedCount} Hydrogen skills to ${result.destinationRoots.join(", ")}.`);
975
+ await syncSkills({
976
+ force: options.force,
977
+ cwd: appRoot,
978
+ packageRoot: options.packageRoot,
979
+ log
980
+ });
602
981
  }
603
982
 
604
983
  //#endregion
@@ -616,7 +995,19 @@ const COMMANDS = [
616
995
  },
617
996
  {
618
997
  path: ["setup"],
619
- run: async (_args) => setupHydrogen()
998
+ run: async (args) => setupHydrogen(parseSkillsSyncArgs(args))
999
+ },
1000
+ {
1001
+ path: ["skills", "check"],
1002
+ run: async (args) => {
1003
+ checkSkills(parseSkillsCheckArgs(args));
1004
+ }
1005
+ },
1006
+ {
1007
+ path: ["skills", "sync"],
1008
+ run: async (args) => {
1009
+ await syncSkills(parseSkillsSyncArgs(args));
1010
+ }
620
1011
  },
621
1012
  {
622
1013
  path: ["gql", "check"],
@@ -182,6 +182,8 @@ function parseAddIntent(form, merchandiseId) {
182
182
  quantity
183
183
  };
184
184
  if (sellingPlanId) line.sellingPlanId = sellingPlanId;
185
+ const rawAttributes = getCartAttributeFormEntries(form);
186
+ if (rawAttributes.length > 0) line.attributes = parseAttributes(rawAttributes);
185
187
  return {
186
188
  intent: "add",
187
189
  lines: [line]