@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.
- package/README.md +32 -1
- package/dist/cli/index.mjs +464 -73
- package/dist/core/cart/actions.mjs +2 -0
- package/dist/core/cart/actions.mjs.map +1 -1
- package/dist/core/cart/cart.mjs +16 -6
- package/dist/core/cart/cart.mjs.map +1 -1
- package/dist/core/product/form.d.mts +21 -2
- package/dist/core/product/form.mjs +13 -0
- package/dist/core/product/form.mjs.map +1 -1
- package/dist/core/product/index.d.mts +1 -1
- package/dist/core/request-context.d.mts +6 -0
- package/dist/core/request-context.mjs +6 -2
- package/dist/core/request-context.mjs.map +1 -1
- package/dist/core/request-routing/handle-shopify-routes.mjs +2 -0
- package/dist/core/request-routing/handle-shopify-routes.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/checkout.mjs +4 -1
- package/dist/core/request-routing/interceptors/checkout.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/proxy.mjs +19 -8
- package/dist/core/request-routing/interceptors/proxy.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
- package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
- package/dist/core/shopify-scripts/analytics.mjs +1 -1
- package/dist/core/shopify-scripts/analytics.mjs.map +1 -1
- package/dist/core/shopify-scripts/constants.mjs +3 -1
- package/dist/core/shopify-scripts/constants.mjs.map +1 -1
- package/dist/core/shopify-scripts/deprecated-cookies.mjs +2 -1
- package/dist/core/shopify-scripts/deprecated-cookies.mjs.map +1 -1
- package/dist/core/shopify-scripts/global.mjs +5 -4
- package/dist/core/shopify-scripts/global.mjs.map +1 -1
- package/dist/core/shopify-scripts/index.d.mts +1 -0
- package/dist/core/shopify-scripts/index.mjs +14 -12
- package/dist/core/shopify-scripts/index.mjs.map +1 -1
- package/dist/core/shopify-scripts/types.d.mts +5 -0
- package/dist/core/url.mjs +2 -1
- package/dist/core/url.mjs.map +1 -1
- package/dist/customer-account/client.mjs +1 -1
- package/dist/customer-account/session.mjs +1 -1
- package/dist/development/core/cart/actions.mjs +2 -0
- package/dist/development/core/cart/actions.mjs.map +1 -1
- package/dist/development/core/cart/cart.mjs +16 -6
- package/dist/development/core/cart/cart.mjs.map +1 -1
- package/dist/development/core/cart/queries.d.mts +57 -57
- package/dist/development/core/predictive-search/queries.d.mts +2 -2
- package/dist/development/core/product/form.d.mts +21 -2
- package/dist/development/core/product/form.mjs +13 -0
- package/dist/development/core/product/form.mjs.map +1 -1
- package/dist/development/core/product/index.d.mts +1 -1
- package/dist/development/core/request-context.d.mts +6 -0
- package/dist/development/core/request-context.mjs +6 -2
- package/dist/development/core/request-context.mjs.map +1 -1
- package/dist/development/core/request-routing/handle-shopify-routes.mjs +2 -0
- package/dist/development/core/request-routing/handle-shopify-routes.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/checkout.mjs +4 -1
- package/dist/development/core/request-routing/interceptors/checkout.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/proxy.mjs +19 -8
- package/dist/development/core/request-routing/interceptors/proxy.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
- package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
- package/dist/development/core/shopify-scripts/analytics.mjs +1 -1
- package/dist/development/core/shopify-scripts/analytics.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/constants.mjs +3 -1
- package/dist/development/core/shopify-scripts/constants.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/deprecated-cookies.mjs +2 -1
- package/dist/development/core/shopify-scripts/deprecated-cookies.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/global.mjs +5 -4
- package/dist/development/core/shopify-scripts/global.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/index.d.mts +1 -0
- package/dist/development/core/shopify-scripts/index.mjs +14 -12
- package/dist/development/core/shopify-scripts/index.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/types.d.mts +5 -0
- package/dist/development/core/url.mjs +2 -1
- package/dist/development/core/url.mjs.map +1 -1
- package/dist/development/globals.d.mts +4 -1
- package/dist/development/react/index.d.mts +2 -2
- package/dist/development/vue/shopify-scripts.d.mts +9 -0
- package/dist/development/vue/shopify-scripts.mjs +5 -0
- package/dist/development/vue/shopify-scripts.mjs.map +1 -1
- package/dist/globals.d.mts +4 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/vue/shopify-scripts.d.mts +10 -1
- package/dist/vue/shopify-scripts.mjs +5 -0
- package/dist/vue/shopify-scripts.mjs.map +1 -1
- package/package.json +1 -1
- package/skills/hydrogen-cart-metafields/SKILL.md +133 -0
- package/skills/hydrogen-cart-ui/SKILL.md +2 -1
- package/skills/hydrogen-setup/steps/11-verify.md +1 -0
- package/skills/hydrogen-setup/steps/2-scaffold.md +21 -0
- package/skills/hydrogen-storefront-client/SKILL.md +4 -0
- package/skills/hydrogen-variant-form/SKILL.md +10 -4
- package/dist/core/shopify-scripts/consent.mjs +0 -17
- package/dist/core/shopify-scripts/consent.mjs.map +0 -1
- package/dist/development/core/shopify-scripts/consent.mjs +0 -17
- 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
|
|
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
|
|
package/dist/cli/index.mjs
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
-
|
|
601
|
-
|
|
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 (
|
|
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]
|