From 98e1e59c504e5be20745c03a0067550e9dd6e65b Mon Sep 17 00:00:00 2001 From: David Cameron Date: Sun, 27 Apr 2025 23:28:59 -0400 Subject: [PATCH 1/6] Add utilities for API version management and schema updates - Add check-api-version.js to automatically update API versions based on current date - Add generate-app.js to manage shopify.app.toml configuration - Add update-schemas.js to automate schema updates using Shopify CLI - Update expand-liquid.js to handle extension handles more robustly - Update README.md with comprehensive instructions - Add npm scripts and required dependencies This facilitates automatic updating of schemas and API versions across all function examples, making maintenance easier. --- .gitignore | 1 + README.md | 23 ++++++ package.json | 20 +++-- util/check-api-version.js | 60 +++++++++++++++ util/check-js-dependencies.js | 58 +++++++++++++++ util/check-rust-dependencies.js | 106 +++++++++++++++++++++++++++ util/expand-liquid.js | 33 +++++++-- util/generate-app.js | 125 ++++++++++++++++++++++++++++++++ util/update-schemas.js | 118 ++++++++++++++++++++++++++++++ 9 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 util/check-api-version.js create mode 100644 util/check-js-dependencies.js create mode 100644 util/check-rust-dependencies.js create mode 100644 util/generate-app.js create mode 100644 util/update-schemas.js diff --git a/.gitignore b/.gitignore index 2be74316..9fe87f95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ sample-apps/*/public/build/* !sample-apps/**/shopify.extension.toml !sample-apps/**/locales/*.json dev.sqlite +shopify.app.toml diff --git a/README.md b/README.md index b02ecb8f..7713703b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,29 @@ yarn expand-liquid vanilla-js yarn expand-liquid typescript ``` +### Update API Versions and Function Schemas + +To update API versions and function schemas automatically: + +```shell +# Step 1: Link to a Shopify app to create shopify.app.toml with client_id +shopify app config link + +# Step 2: Generate/update the extension directories list in shopify.app.toml +yarn generate-app + +# Step 3: Run the comprehensive update command +yarn update-api-version +``` + +This process: +1. First, links to a Shopify app to create shopify.app.toml with the client ID +2. Then adds all extension directories to the same file (preserving the client_id) +3. Finally, runs a sequence of commands that: + - Updates API versions across all extensions + - Expands liquid templates + - Updates function schemas + ### Run Tests ```shell diff --git a/package.json b/package.json index 3677e6ae..117ca62b 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,30 @@ "version": "1.0.0", "type": "module", "devDependencies": { - "@iarna/toml": "^2.2.5", - "fast-glob": "^3.2.11", - "liquidjs": "^9.37.0", "@graphql-codegen/cli": "^3.2.2", "@graphql-codegen/typescript": "^3.0.2", "@graphql-codegen/typescript-operations": "^3.0.2", - "graphql": "^16.6.0" + "@iarna/toml": "^2.2.5", + "dayjs": "^1.11.11", + "fast-glob": "^3.2.11", + "graphql": "^16.6.0", + "liquidjs": "^9.37.0" }, "scripts": { "expand-liquid": "node ./util/expand-liquid.js", "typegen": "yarn workspaces run graphql-code-generator --config package.json", "test-js": "yarn expand-liquid vanilla-js && yarn && yarn typegen && yarn workspaces run test run", - "test-ts": "yarn expand-liquid typescript && yarn && yarn typegen && yarn workspaces run test run" + "test-ts": "yarn expand-liquid typescript && yarn && yarn typegen && yarn workspaces run test run", + "check-api-version": "node ./util/check-api-version.js", + "check-js-dependencies": "node ./util/check-js-dependencies.js", + "check-rust-dependencies": "node ./util/check-rust-dependencies.js", + "check-rust": "yarn check-rust-dependencies && yarn expand-liquid && cargo test", + "generate-app": "node ./util/generate-app.js", + "update-schemas": "node ./util/update-schemas.js", + "update-api-version": "yarn check-api-version && yarn expand-liquid && yarn update-schemas" }, "private": true, "workspaces": [ "*/javascript/**" ] -} +} \ No newline at end of file diff --git a/util/check-api-version.js b/util/check-api-version.js new file mode 100644 index 00000000..223c92d0 --- /dev/null +++ b/util/check-api-version.js @@ -0,0 +1,60 @@ +import fs from 'fs/promises'; +import fastGlob from 'fast-glob'; +import dayjs from 'dayjs'; + +const ROOT_DIR = '.'; +const FILE_PATTERN = '**/shopify.extension.toml.liquid'; + +// Method to get the latest API version based on today's date +function getLatestApiVersion() { + const date = dayjs(); + const year = date.year(); + const month = date.month(); + const quarter = Math.floor(month / 3); + + const monthNum = quarter * 3 + 1; + const paddedMonth = String(monthNum).padStart(2, '0'); + + return `${year}-${paddedMonth}`; +} + +// Method to find all shopify.extension.toml.liquid files +async function findAllExtensionFiles() { + return fastGlob(FILE_PATTERN, { cwd: ROOT_DIR, absolute: true }); +} + +// Method to update the API version in the file +async function updateApiVersion(filePath, latestVersion) { + const content = await fs.readFile(filePath, 'utf8'); + const updatedContent = content.replace(/api_version\s*=\s*"\d{4}-\d{2}"/, `api_version = "${latestVersion}"`); + + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated API version in ${filePath}`); +} + +// Main method to check and update API versions +async function checkAndUpdateApiVersions() { + const latestVersion = getLatestApiVersion(); + const extensionFiles = await findAllExtensionFiles(); + + for (const filePath of extensionFiles) { + const content = await fs.readFile(filePath, 'utf8'); + const match = content.match(/api_version\s*=\s*"(\d{4}-\d{2})"/); + if (match) { + const currentVersion = match[1]; + + if (currentVersion !== latestVersion) { + await updateApiVersion(filePath, latestVersion); + } else { + console.log(`API version in ${filePath} is already up to date.`); + } + } else { + console.warn(`No API version found in ${filePath}`); + } + } +} + +checkAndUpdateApiVersions().catch(error => { + console.error('Error checking and updating API versions:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/check-js-dependencies.js b/util/check-js-dependencies.js new file mode 100644 index 00000000..1dc23897 --- /dev/null +++ b/util/check-js-dependencies.js @@ -0,0 +1,58 @@ +import fs from 'fs/promises'; +import path from 'path'; +import fastGlob from 'fast-glob'; +import { execSync } from 'child_process'; + +const ROOT_DIR = '.'; +const FILE_PATTERN = '**/package.json.liquid'; + +async function findAllPackageFiles() { + return fastGlob(FILE_PATTERN, { cwd: ROOT_DIR, absolute: true }); +} + +async function getLatestVersion(packageName) { + try { + // Fetch the latest version of a package from the npm registry + const output = execSync(`npm show ${packageName} version`, { encoding: 'utf8' }); + return output.trim(); + } catch (error) { + console.warn(`Could not fetch version for package ${packageName}:`, error.message); + return null; + } +} + +async function checkAndUpdateDependencies(filePath) { + const content = await fs.readFile(filePath, 'utf8'); + const jsonContent = JSON.parse(content); + + const { dependencies = {}, devDependencies = {} } = jsonContent; + + const updateDependencyVersion = async (dependencies) => { + for (const [name, currentVersion] of Object.entries(dependencies)) { + const latestVersion = await getLatestVersion(name); + if (latestVersion && currentVersion !== `^${latestVersion}`) { + console.log(`Updating ${name} from ${currentVersion} to ^${latestVersion}`); + dependencies[name] = `^${latestVersion}`; + } + } + }; + + await updateDependencyVersion(dependencies); + await updateDependencyVersion(devDependencies); + + const updatedContent = JSON.stringify(jsonContent, null, 2); + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated dependencies in ${filePath}`); +} + +async function main() { + const packageFiles = await findAllPackageFiles(); + for (const filePath of packageFiles) { + await checkAndUpdateDependencies(filePath); + } +} + +main().catch(error => { + console.error('Error checking and updating dependencies:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/check-rust-dependencies.js b/util/check-rust-dependencies.js new file mode 100644 index 00000000..88c15642 --- /dev/null +++ b/util/check-rust-dependencies.js @@ -0,0 +1,106 @@ +import fs from 'fs/promises'; +import fastGlob from 'fast-glob'; +import toml from '@iarna/toml'; + +const ROOT_DIR = '.'; +const FILE_PATTERNS = ['**/Cargo.toml', '**/Cargo.toml.liquid']; +const LIQUID_PLACEHOLDER = 'LIQUID_PLACEHOLDER'; + +async function findAllCargoFiles() { + return fastGlob(FILE_PATTERNS, { cwd: ROOT_DIR, absolute: true }); +} + +async function getLatestVersion(packageName) { + try { + // Fetch the latest version of a package from crates.io + const response = await fetch(`https://crates.io/api/v1/crates/${packageName}`); + const jsonResponse = await response.json(); + return jsonResponse.crate.max_version; + } catch (error) { + console.warn(`Could not fetch version for package ${packageName}:`, error.message); + return null; + } +} + +async function updateDependencyVersion(name, currentVersion) { + const latestVersion = await getLatestVersion(name); + + if (latestVersion) { + if (typeof currentVersion === 'string') { + if (!currentVersion.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion} to ${latestVersion}`); + return latestVersion; + } + } else if (typeof currentVersion === 'object' && 'version' in currentVersion) { + if (!currentVersion.version.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion.version} to ${latestVersion}`); + return { ...currentVersion, version: latestVersion }; + } + } + } + return currentVersion; +} + +function preprocessLiquidSyntax(content) { + const liquidExpressions = []; + const placeholderContent = content.replace(/\{\{.*?\}\}|\{%\s.*?\s%\}/g, (match) => { + liquidExpressions.push(match); + return `{${LIQUID_PLACEHOLDER}:${liquidExpressions.length - 1}}`; + }); + return { placeholderContent, liquidExpressions }; +} + +function restoreLiquidSyntax(content, liquidExpressions) { + return content.replace(new RegExp(`\\{${LIQUID_PLACEHOLDER}:(\\d+)\\}`, 'g'), (match, index) => { + return liquidExpressions[Number(index)]; + }); +} + +async function checkAndUpdateDependencies(filePath) { + let content = await fs.readFile(filePath, 'utf8'); + + const isLiquidFile = filePath.endsWith('.liquid'); + let liquidExpressions = []; + + if (isLiquidFile) { + const processed = preprocessLiquidSyntax(content); + content = processed.placeholderContent; + liquidExpressions = processed.liquidExpressions; + } + + let tomlData; + try { + tomlData = toml.parse(content); + } catch (error) { + console.error(`Failed to parse TOML in file: ${filePath}`, error.message); + return; + } + + if (tomlData.dependencies) { + const dependencyNames = Object.keys(tomlData.dependencies); + for (const name of dependencyNames) { + const currentVersion = tomlData.dependencies[name]; + tomlData.dependencies[name] = await updateDependencyVersion(name, currentVersion); + } + } + + let updatedContent = toml.stringify(tomlData); + if (isLiquidFile) { + updatedContent = restoreLiquidSyntax(updatedContent, liquidExpressions); + } + + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated dependencies in ${filePath}`); +} + +async function main() { + const cargoFiles = await findAllCargoFiles(); + for (const filePath of cargoFiles) { + await checkAndUpdateDependencies(filePath); + } +} + +main().catch(error => { + console.error('Error checking and updating dependencies:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/expand-liquid.js b/util/expand-liquid.js index bcd4031e..b5e6ef8d 100644 --- a/util/expand-liquid.js +++ b/util/expand-liquid.js @@ -62,6 +62,7 @@ async function directoryNames(parentPath) { async function expandExtensionLiquidTemplates(domainName, flavor) { console.log(`Expanding liquid templates for ${domainName}`); const domainPath = path.join(process.cwd(), domainName); + let handleCounter = 1; // Counter for potentially truncated handles within this domain const langNames = await directoryNames(domainPath); for (const langName of langNames) { @@ -79,18 +80,36 @@ async function expandExtensionLiquidTemplates(domainName, flavor) { await (await glob(path.join(templatePath, 'src', '!(*.liquid|*.graphql)'))).forEach(async (path) => await fs.rm(path)); } + let baseHandle = `${langName}-${domainName}-${extensionTypeName}-${templateName}`; + let handle; + + if (baseHandle.length > 30) { + // Truncate to leave space for a counter suffix (e.g., '-99') + const maxBaseLength = 27; + // Remove trailing hyphen if present after slicing, then add counter + handle = `${baseHandle.slice(0, maxBaseLength).replace(/-$/, '')}-${handleCounter}`; + // Ensure final handle does not exceed 30 characters (safeguard) + if (handle.length > 30) { + handle = handle.slice(0, 30); + } + handleCounter++; // Increment counter only when a handle is truncated + } else { + // Use the base handle if it's within the length limit + handle = baseHandle; + } + const liquidData = { - name: `${domainName}-${extensionTypeName}-${templateName}`, - handle: `${domainName}-${extensionTypeName}-${templateName}`, + name: `${langName}-${domainName}-${extensionTypeName}-${templateName}`, // Keep original descriptive name + handle, // Use the potentially modified handle flavor, }; await expandLiquidTemplates(templatePath, liquidData); if (langName === "javascript") { - const srcFilePaths = await glob(path.join(templatePath, 'src', '!(*.liquid|*.graphql)')) - const srcFileExtensionsToChange = [] - + const srcFilePaths = await glob(path.join(templatePath, 'src', '!(*.liquid|*.graphql)')); + const srcFileExtensionsToChange = []; + const fileExtension = flavor === "typescript" ? "ts" : "js"; for (const srcFilePath of srcFilePaths) { @@ -99,7 +118,7 @@ async function expandExtensionLiquidTemplates(domainName, flavor) { })); } - await Promise.all(srcFileExtensionsToChange) + await Promise.all(srcFileExtensionsToChange); } } } @@ -141,4 +160,4 @@ console.log('The above files should be added to .gitignore if they have not alre if (process.env.CI) { ensureNoGitChanges(); -} +} \ No newline at end of file diff --git a/util/generate-app.js b/util/generate-app.js new file mode 100644 index 00000000..8ed73e27 --- /dev/null +++ b/util/generate-app.js @@ -0,0 +1,125 @@ +import fs from 'fs/promises'; +import path from 'path'; +import fastGlob from 'fast-glob'; +import { existsSync } from 'fs'; + +const ROOT_DIR = '.'; +const FILE_PATTERN = '**/shopify.extension.toml.liquid'; +const EXCLUDED_DIRS = ['samples', 'sample-apps', 'node_modules']; +const OUTPUT_FILE = 'shopify.app.toml'; + +// Method to find all shopify.extension.toml.liquid files excluding specified directories +async function findAllExtensionFiles() { + return fastGlob(FILE_PATTERN, { + cwd: ROOT_DIR, + absolute: true, + ignore: EXCLUDED_DIRS.map(dir => `${dir}/**`) + }); +} + +// Method to format directories for toml +function formatDirectoriesForToml(directories) { + return directories.map(dir => `'${dir}'`).join(',\n '); +} + +// Method to read existing shopify.app.toml if it exists +async function readExistingToml() { + try { + if (existsSync(OUTPUT_FILE)) { + return await fs.readFile(OUTPUT_FILE, 'utf8'); + } + return null; + } catch (error) { + console.error(`Error reading ${OUTPUT_FILE}:`, error); + return null; + } +} + +// Main method to update or create the shopify.app.toml file +async function updateAppToml() { + const extensionFiles = await findAllExtensionFiles(); + + // Transform paths to be relative to root and exclude the filenames + const extensionDirectories = extensionFiles.map(filePath => path.relative(ROOT_DIR, path.dirname(filePath))); + + // Remove duplicates + const uniqueDirectories = [...new Set(extensionDirectories)]; + + // Format directories for TOML + const formattedDirectories = formatDirectoriesForToml(uniqueDirectories); + + // Read existing content + const existingContent = await readExistingToml(); + + let newContent; + + if (existingContent) { + // Extract key parts from the existing content + let mainConfig = ''; + let webhooksSection = ''; + let accessScopesSection = ''; + let authSection = ''; + let posSection = ''; + let otherSections = ''; + + // Extract the main configuration (up to the first section) + const mainMatch = existingContent.match(/^([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); + if (mainMatch) { + mainConfig = mainMatch[1].trim(); + } + + // Extract webhooks section + const webhooksMatch = existingContent.match(/\[webhooks\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); + if (webhooksMatch) { + webhooksSection = `\n\n[webhooks]${webhooksMatch[1]}`; + if (webhooksMatch[2] && !webhooksMatch[2].startsWith('[')) { + webhooksSection = webhooksSection.trim(); + } + } + + // Extract access_scopes section + const scopesMatch = existingContent.match(/\[access_scopes\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); + if (scopesMatch) { + accessScopesSection = `\n\n[access_scopes]${scopesMatch[1]}`; + if (scopesMatch[2] && !scopesMatch[2].startsWith('[')) { + accessScopesSection = accessScopesSection.trim(); + } + } + + // Extract auth section + const authMatch = existingContent.match(/\[auth\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); + if (authMatch) { + authSection = `\n\n[auth]${authMatch[1]}`; + if (authMatch[2] && !authMatch[2].startsWith('[')) { + authSection = authSection.trim(); + } + } + + // Extract pos section + const posMatch = existingContent.match(/\[pos\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); + if (posMatch) { + posSection = `\n\n[pos]${posMatch[1]}`; + if (posMatch[2] && !posMatch[2].startsWith('[')) { + posSection = posSection.trim(); + } + } + + // Build the new content with directories before other sections + newContent = `${mainConfig}\n\n\nextension_directories = [\n ${formattedDirectories}\n]\n\nweb_directories = []${webhooksSection}${accessScopesSection}${authSection}${posSection}${otherSections}`; + + } else { + // Create a new file with extension_directories and web_directories + newContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +extension_directories = [\n ${formattedDirectories}\n]\n\nweb_directories = []\n`; + } + + // Write the updated content to the file + await fs.writeFile(OUTPUT_FILE, newContent, 'utf8'); + console.log(`Updated ${OUTPUT_FILE} with extension directories`); +} + +updateAppToml().catch(error => { + console.error('Error updating shopify.app.toml file:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/update-schemas.js b/util/update-schemas.js new file mode 100644 index 00000000..80b019cb --- /dev/null +++ b/util/update-schemas.js @@ -0,0 +1,118 @@ +import fs from 'fs/promises'; +import { exec } from 'child_process'; +import path from 'path'; +import util from 'util'; +import { existsSync } from 'fs'; + +const execPromise = util.promisify(exec); +const APP_TOML_FILE = 'shopify.app.toml'; +const COMMAND_TEMPLATE = 'shopify app function schema'; + +// Method to read shopify.app.toml and extract needed configuration +async function getConfig() { + try { + if (!existsSync(APP_TOML_FILE)) { + throw new Error(`${APP_TOML_FILE} does not exist. Run 'shopify app config link' first.`); + } + + const content = await fs.readFile(APP_TOML_FILE, 'utf8'); + const lines = content.split('\n'); + + const config = { + clientId: '', + directories: [] + }; + + let inExtensionDirectories = false; + const dirRegex = /'([^']+)'/g; + const quoteRegex = /"([^"]+)"/g; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Extract client_id + if (trimmedLine.startsWith('client_id')) { + const match = line.match(quoteRegex); + if (match) { + config.clientId = match[0].replace(/"/g, ''); + } + continue; + } + + // Check if we're entering the extension_directories section + if (trimmedLine.startsWith('extension_directories')) { + inExtensionDirectories = true; + continue; + } + + // Check if we're leaving the extension_directories section + if (inExtensionDirectories && trimmedLine.startsWith(']')) { + inExtensionDirectories = false; + continue; + } + + // Extract directories only when in extension_directories section + if (inExtensionDirectories) { + // Try to match with both single and double quotes + let match = trimmedLine.match(dirRegex); + if (!match) { + match = trimmedLine.match(quoteRegex); + } + + if (match) { + const cleanDir = match[0].replace(/['"]/g, '').trim(); + if (cleanDir && existsSync(cleanDir)) { + config.directories.push(cleanDir); + } + } + } + } + + return config; + } catch (error) { + console.error(`Error reading ${APP_TOML_FILE}:`, error); + throw error; + } +} + +// Method to run the command for each directory +async function updateSchemas() { + try { + const config = await getConfig(); + + if (!config.clientId) { + throw new Error('Client ID not found in shopify.app.toml'); + } + + if (config.directories.length === 0) { + console.warn('No valid extension directories found in shopify.app.toml'); + return; + } + + console.log(`Found ${config.directories.length} extension directories`); + console.log(`Using client ID: ${config.clientId}`); + + for (const dir of config.directories) { + try { + const command = `${COMMAND_TEMPLATE} --path ${dir}`; + console.log(`\nUpdating schema for: ${dir}`); + console.log(`Running: ${command}`); + + const { stdout, stderr } = await execPromise(command); + if (stdout) console.log(`Output: ${stdout.trim()}`); + if (stderr && !stderr.includes('warning')) console.error(`Error: ${stderr.trim()}`); + } catch (error) { + console.error(`Failed to update schema for ${dir}:`, error.message); + } + } + + console.log("\nSchema update completed"); + } catch (error) { + console.error('Failed to update schemas:', error); + } +} + +updateSchemas().catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file From 8a78179055d0f27616e47c4732dd7609534e7869 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Sun, 27 Apr 2025 23:35:24 -0400 Subject: [PATCH 2/6] Document dependency update utilities in README Add instructions for using: - check-js-dependencies to update JavaScript/TypeScript dependencies - check-rust-dependencies to update Rust crate dependencies --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 7713703b..872c02fc 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,19 @@ cargo test cargo fmt cargo clippy -- -D warnings ``` + +### Update Dependencies + +To check and update JavaScript dependencies in all package.json.liquid files: + +```shell +yarn check-js-dependencies +``` + +To check and update Rust dependencies in all Cargo.toml and Cargo.toml.liquid files: + +```shell +yarn check-rust-dependencies +``` + +These utilities will fetch the latest versions from npm and crates.io respectively and update your templates. From 273766749438c244705a677c3db00537adf3caf9 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Tue, 29 Apr 2025 11:10:30 -0400 Subject: [PATCH 3/6] Use @shopify/toml-patch for TOML manipulation - Added @shopify/toml-patch package as a dependency - Replaced brittle manual TOML parsing in check-rust-dependencies.js - Updated check-api-version.js to use the more robust TOML patch library - Added proper handling of Liquid templates during TOML manipulation - Improved error handling and reporting --- package.json | 1 + util/check-api-version.js | 84 +++++++++++++++++++++++++++------ util/check-rust-dependencies.js | 78 +++++++++++++++++++++--------- 3 files changed, 127 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 117ca62b..ed980ed8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@graphql-codegen/typescript": "^3.0.2", "@graphql-codegen/typescript-operations": "^3.0.2", "@iarna/toml": "^2.2.5", + "@shopify/toml-patch": "^1.0.0", "dayjs": "^1.11.11", "fast-glob": "^3.2.11", "graphql": "^16.6.0", diff --git a/util/check-api-version.js b/util/check-api-version.js index 223c92d0..b8b77443 100644 --- a/util/check-api-version.js +++ b/util/check-api-version.js @@ -1,9 +1,11 @@ import fs from 'fs/promises'; import fastGlob from 'fast-glob'; import dayjs from 'dayjs'; +import { updateTomlValues } from '@shopify/toml-patch'; const ROOT_DIR = '.'; const FILE_PATTERN = '**/shopify.extension.toml.liquid'; +const LIQUID_PLACEHOLDER = 'LIQUID_PLACEHOLDER'; // Method to get the latest API version based on today's date function getLatestApiVersion() { @@ -23,33 +25,85 @@ async function findAllExtensionFiles() { return fastGlob(FILE_PATTERN, { cwd: ROOT_DIR, absolute: true }); } -// Method to update the API version in the file -async function updateApiVersion(filePath, latestVersion) { - const content = await fs.readFile(filePath, 'utf8'); - const updatedContent = content.replace(/api_version\s*=\s*"\d{4}-\d{2}"/, `api_version = "${latestVersion}"`); +// Function to preprocess liquid syntax +function preprocessLiquidSyntax(content) { + const liquidExpressions = []; + const placeholderContent = content.replace(/\{\{.*?\}\}|\{%\s.*?\s%\}/g, (match) => { + liquidExpressions.push(match); + return `{${LIQUID_PLACEHOLDER}:${liquidExpressions.length - 1}}`; + }); + return { placeholderContent, liquidExpressions }; +} - await fs.writeFile(filePath, updatedContent, 'utf8'); - console.log(`Updated API version in ${filePath}`); +// Function to restore liquid syntax +function restoreLiquidSyntax(content, liquidExpressions) { + return content.replace(new RegExp(`\\{${LIQUID_PLACEHOLDER}:(\\d+)\\}`, 'g'), (match, index) => { + return liquidExpressions[Number(index)]; + }); +} + +// Method to update the API version in the file using toml-patch +async function updateApiVersion(filePath, latestVersion) { + try { + const content = await fs.readFile(filePath, 'utf8'); + + // Handle liquid templates if needed + const isLiquidFile = filePath.endsWith('.liquid'); + let liquidExpressions = []; + let processedContent = content; + + if (isLiquidFile) { + const processed = preprocessLiquidSyntax(content); + processedContent = processed.placeholderContent; + liquidExpressions = processed.liquidExpressions; + } + + // Use toml-patch to update the API version + const updates = [ + [['api_version'], latestVersion] + ]; + + let updatedContent = updateTomlValues(processedContent, updates); + + // Restore liquid syntax if needed + if (isLiquidFile) { + updatedContent = restoreLiquidSyntax(updatedContent, liquidExpressions); + } + + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated API version in ${filePath} to ${latestVersion}`); + + } catch (error) { + console.error(`Error updating API version in ${filePath}:`, error.message); + } } // Main method to check and update API versions async function checkAndUpdateApiVersions() { const latestVersion = getLatestApiVersion(); + console.log(`Latest API version: ${latestVersion}`); const extensionFiles = await findAllExtensionFiles(); + console.log(`Found ${extensionFiles.length} extension files to check`); for (const filePath of extensionFiles) { - const content = await fs.readFile(filePath, 'utf8'); - const match = content.match(/api_version\s*=\s*"(\d{4}-\d{2})"/); - if (match) { - const currentVersion = match[1]; + try { + const content = await fs.readFile(filePath, 'utf8'); + const match = content.match(/api_version\s*=\s*"(\d{4}-\d{2})"/); - if (currentVersion !== latestVersion) { - await updateApiVersion(filePath, latestVersion); + if (match) { + const currentVersion = match[1]; + + if (currentVersion !== latestVersion) { + console.log(`Updating ${filePath} from ${currentVersion} to ${latestVersion}`); + await updateApiVersion(filePath, latestVersion); + } else { + console.log(`API version in ${filePath} is already up to date (${currentVersion}).`); + } } else { - console.log(`API version in ${filePath} is already up to date.`); + console.warn(`No API version found in ${filePath}`); } - } else { - console.warn(`No API version found in ${filePath}`); + } catch (error) { + console.error(`Error processing ${filePath}:`, error.message); } } } diff --git a/util/check-rust-dependencies.js b/util/check-rust-dependencies.js index 88c15642..a6b92a20 100644 --- a/util/check-rust-dependencies.js +++ b/util/check-rust-dependencies.js @@ -1,6 +1,6 @@ import fs from 'fs/promises'; import fastGlob from 'fast-glob'; -import toml from '@iarna/toml'; +import { updateTomlValues } from '@shopify/toml-patch'; const ROOT_DIR = '.'; const FILE_PATTERNS = ['**/Cargo.toml', '**/Cargo.toml.liquid']; @@ -68,29 +68,65 @@ async function checkAndUpdateDependencies(filePath) { liquidExpressions = processed.liquidExpressions; } - let tomlData; try { - tomlData = toml.parse(content); - } catch (error) { - console.error(`Failed to parse TOML in file: ${filePath}`, error.message); - return; - } - - if (tomlData.dependencies) { - const dependencyNames = Object.keys(tomlData.dependencies); - for (const name of dependencyNames) { - const currentVersion = tomlData.dependencies[name]; - tomlData.dependencies[name] = await updateDependencyVersion(name, currentVersion); + // Get all dependencies and their latest versions + const updates = []; + + // Simple regex to extract dependencies section + const depsMatch = content.match(/\[dependencies\]([\s\S]*?)(\[|\Z)/); + if (depsMatch) { + const depsSection = depsMatch[1]; + // Extract dependency names with a simple regex + const depMatches = depsSection.matchAll(/^([a-zA-Z0-9_-]+)\s*=\s*("[^"]*"|{[^}]*})/gm); + + for (const match of depMatches) { + const name = match[1].trim(); + const currentDef = match[2]; + + // Only process string dependencies for now + if (currentDef.startsWith('"')) { + const currentVersion = currentDef.replace(/"/g, ''); + const latestVersion = await getLatestVersion(name); + + if (latestVersion && !currentVersion.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion} to ${latestVersion}`); + updates.push([['dependencies', name], latestVersion]); + } + } else if (currentDef.startsWith('{')) { + // Extract version from table syntax like { version = "1.0.0", features = ["derive"] } + const versionMatch = currentDef.match(/version\s*=\s*"([^"]*)"/); + if (versionMatch) { + const currentVersion = versionMatch[1]; + const latestVersion = await getLatestVersion(name); + + if (latestVersion && !currentVersion.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion} to ${latestVersion}`); + // For table dependencies, we need to update just the version field + // This approach preserves other fields like features + const newDef = currentDef.replace(/version\s*=\s*"[^"]*"/, `version = "${latestVersion}"`); + updates.push([['dependencies', name], newDef.replace(/[{}]/g, '').trim()]); + } + } + } + } } + + // Only update the file if we have changes + if (updates.length > 0) { + let updatedContent = updateTomlValues(content, updates); + + if (isLiquidFile) { + updatedContent = restoreLiquidSyntax(updatedContent, liquidExpressions); + } + + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated dependencies in ${filePath}`); + } else { + console.log(`No updates needed for ${filePath}`); + } + } catch (error) { + console.error(`Failed to update TOML in file: ${filePath}`, error.message); } - - let updatedContent = toml.stringify(tomlData); - if (isLiquidFile) { - updatedContent = restoreLiquidSyntax(updatedContent, liquidExpressions); - } - - await fs.writeFile(filePath, updatedContent, 'utf8'); - console.log(`Updated dependencies in ${filePath}`); } async function main() { From ba8de3c98d9cf73f1a1cadeea15f1bfcf3eb3086 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Tue, 29 Apr 2025 11:18:32 -0400 Subject: [PATCH 4/6] Update generate-app.js to use toml-patch library directly - Replace manual string manipulation with @shopify/toml-patch for TOML operations - Simplify directory listing handling with automatic array formatting - Remove unneeded formatDirectoriesForToml helper function --- util/generate-app.js | 121 ++++++++++++++----------------------------- 1 file changed, 39 insertions(+), 82 deletions(-) diff --git a/util/generate-app.js b/util/generate-app.js index 8ed73e27..06a08651 100644 --- a/util/generate-app.js +++ b/util/generate-app.js @@ -2,6 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import fastGlob from 'fast-glob'; import { existsSync } from 'fs'; +import { updateTomlValues } from '@shopify/toml-patch'; const ROOT_DIR = '.'; const FILE_PATTERN = '**/shopify.extension.toml.liquid'; @@ -17,10 +18,7 @@ async function findAllExtensionFiles() { }); } -// Method to format directories for toml -function formatDirectoriesForToml(directories) { - return directories.map(dir => `'${dir}'`).join(',\n '); -} +// Method is no longer needed as we use toml-patch's array handling // Method to read existing shopify.app.toml if it exists async function readExistingToml() { @@ -37,86 +35,45 @@ async function readExistingToml() { // Main method to update or create the shopify.app.toml file async function updateAppToml() { - const extensionFiles = await findAllExtensionFiles(); - - // Transform paths to be relative to root and exclude the filenames - const extensionDirectories = extensionFiles.map(filePath => path.relative(ROOT_DIR, path.dirname(filePath))); - - // Remove duplicates - const uniqueDirectories = [...new Set(extensionDirectories)]; - - // Format directories for TOML - const formattedDirectories = formatDirectoriesForToml(uniqueDirectories); - - // Read existing content - const existingContent = await readExistingToml(); - - let newContent; - - if (existingContent) { - // Extract key parts from the existing content - let mainConfig = ''; - let webhooksSection = ''; - let accessScopesSection = ''; - let authSection = ''; - let posSection = ''; - let otherSections = ''; - - // Extract the main configuration (up to the first section) - const mainMatch = existingContent.match(/^([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); - if (mainMatch) { - mainConfig = mainMatch[1].trim(); - } - - // Extract webhooks section - const webhooksMatch = existingContent.match(/\[webhooks\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); - if (webhooksMatch) { - webhooksSection = `\n\n[webhooks]${webhooksMatch[1]}`; - if (webhooksMatch[2] && !webhooksMatch[2].startsWith('[')) { - webhooksSection = webhooksSection.trim(); - } - } - - // Extract access_scopes section - const scopesMatch = existingContent.match(/\[access_scopes\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); - if (scopesMatch) { - accessScopesSection = `\n\n[access_scopes]${scopesMatch[1]}`; - if (scopesMatch[2] && !scopesMatch[2].startsWith('[')) { - accessScopesSection = accessScopesSection.trim(); - } - } - - // Extract auth section - const authMatch = existingContent.match(/\[auth\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); - if (authMatch) { - authSection = `\n\n[auth]${authMatch[1]}`; - if (authMatch[2] && !authMatch[2].startsWith('[')) { - authSection = authSection.trim(); - } - } - - // Extract pos section - const posMatch = existingContent.match(/\[pos\]([\s\S]*?)(\[\w+\]|extension_directories|web_directories|$)/); - if (posMatch) { - posSection = `\n\n[pos]${posMatch[1]}`; - if (posMatch[2] && !posMatch[2].startsWith('[')) { - posSection = posSection.trim(); - } + try { + const extensionFiles = await findAllExtensionFiles(); + + // Transform paths to be relative to root and exclude the filenames + const extensionDirectories = extensionFiles.map(filePath => path.relative(ROOT_DIR, path.dirname(filePath))); + + // Remove duplicates + const uniqueDirectories = [...new Set(extensionDirectories)]; + + // Read existing content + const existingContent = await readExistingToml(); + + let baseToml; + + if (existingContent) { + // Use existing file as the base + baseToml = existingContent; + } else { + // Create a new base TOML file + baseToml = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +extension_directories = [] +web_directories = [] +`; } - - // Build the new content with directories before other sections - newContent = `${mainConfig}\n\n\nextension_directories = [\n ${formattedDirectories}\n]\n\nweb_directories = []${webhooksSection}${accessScopesSection}${authSection}${posSection}${otherSections}`; - - } else { - // Create a new file with extension_directories and web_directories - newContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -extension_directories = [\n ${formattedDirectories}\n]\n\nweb_directories = []\n`; + + // Update the TOML content with extension directories + const updatedContent = updateTomlValues(baseToml, [ + [['extension_directories'], uniqueDirectories], + [['web_directories'], []] + ]); + + // Write the updated content to the file + await fs.writeFile(OUTPUT_FILE, updatedContent, 'utf8'); + console.log(`Updated ${OUTPUT_FILE} with ${uniqueDirectories.length} extension directories`); + } catch (error) { + console.error(`Error updating ${OUTPUT_FILE}:`, error); + throw error; } - - // Write the updated content to the file - await fs.writeFile(OUTPUT_FILE, newContent, 'utf8'); - console.log(`Updated ${OUTPUT_FILE} with extension directories`); } updateAppToml().catch(error => { From b1fba32ad890603e643d086b7a45369804a157e9 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Tue, 29 Apr 2025 11:19:16 -0400 Subject: [PATCH 5/6] Require existing shopify.app.toml in generate-app utility - Add validation to ensure shopify.app.toml exists before updating - Error with instructions to run 'shopify app config link' if file is missing --- util/generate-app.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/util/generate-app.js b/util/generate-app.js index 06a08651..291acd6a 100644 --- a/util/generate-app.js +++ b/util/generate-app.js @@ -47,20 +47,14 @@ async function updateAppToml() { // Read existing content const existingContent = await readExistingToml(); - let baseToml; - - if (existingContent) { - // Use existing file as the base - baseToml = existingContent; - } else { - // Create a new base TOML file - baseToml = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -extension_directories = [] -web_directories = [] -`; + // Require an existing shopify.app.toml file + if (!existingContent) { + throw new Error(`${OUTPUT_FILE} not found. Please run 'shopify app config link' first to create the file.`); } + // Use existing file as the base + const baseToml = existingContent; + // Update the TOML content with extension directories const updatedContent = updateTomlValues(baseToml, [ [['extension_directories'], uniqueDirectories], From 3eeb22c95b19cdfabefd07fa2c48b6c6cfd9ff49 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Tue, 29 Apr 2025 11:24:59 -0400 Subject: [PATCH 6/6] Use proper TOML parsing for update-schemas utility - Replace manual regex-based TOML parsing with @iarna/toml library - Simplify extension directory extraction logic - Add warning for directories specified in config that don't exist --- util/expand-liquid.js | 2 +- util/update-schemas.js | 63 +++++++++++++----------------------------- 2 files changed, 20 insertions(+), 45 deletions(-) diff --git a/util/expand-liquid.js b/util/expand-liquid.js index b5e6ef8d..6abddd6c 100644 --- a/util/expand-liquid.js +++ b/util/expand-liquid.js @@ -8,7 +8,7 @@ import { Liquid } from 'liquidjs'; import path from 'path'; import fs from 'node:fs/promises'; import { existsSync } from 'fs'; -import toml from '@iarna/toml'; +import toml from '@iarna/toml'; // For parsing only, no manipulation import { exec } from 'child_process'; async function expandLiquidTemplates(template, liquidData) { diff --git a/util/update-schemas.js b/util/update-schemas.js index 80b019cb..7d502862 100644 --- a/util/update-schemas.js +++ b/util/update-schemas.js @@ -3,6 +3,7 @@ import { exec } from 'child_process'; import path from 'path'; import util from 'util'; import { existsSync } from 'fs'; +import toml from '@iarna/toml'; const execPromise = util.promisify(exec); const APP_TOML_FILE = 'shopify.app.toml'; @@ -16,56 +17,30 @@ async function getConfig() { } const content = await fs.readFile(APP_TOML_FILE, 'utf8'); - const lines = content.split('\n'); + + // Parse the TOML content + const parsedToml = toml.parse(content); const config = { clientId: '', directories: [] }; - - let inExtensionDirectories = false; - const dirRegex = /'([^']+)'/g; - const quoteRegex = /"([^"]+)"/g; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Extract client_id - if (trimmedLine.startsWith('client_id')) { - const match = line.match(quoteRegex); - if (match) { - config.clientId = match[0].replace(/"/g, ''); - } - continue; - } - - // Check if we're entering the extension_directories section - if (trimmedLine.startsWith('extension_directories')) { - inExtensionDirectories = true; - continue; - } - - // Check if we're leaving the extension_directories section - if (inExtensionDirectories && trimmedLine.startsWith(']')) { - inExtensionDirectories = false; - continue; - } - - // Extract directories only when in extension_directories section - if (inExtensionDirectories) { - // Try to match with both single and double quotes - let match = trimmedLine.match(dirRegex); - if (!match) { - match = trimmedLine.match(quoteRegex); - } - - if (match) { - const cleanDir = match[0].replace(/['"]/g, '').trim(); - if (cleanDir && existsSync(cleanDir)) { - config.directories.push(cleanDir); - } + + // Extract client_id if it exists + if (parsedToml.client_id) { + config.clientId = parsedToml.client_id; + } + + // Extract extension directories if they exist + if (parsedToml.extension_directories && Array.isArray(parsedToml.extension_directories)) { + // Filter the directories to ensure they exist + config.directories = parsedToml.extension_directories.filter(dir => { + const exists = existsSync(dir); + if (!exists) { + console.warn(`Directory specified in config does not exist: ${dir}`); } - } + return exists; + }); } return config;