UNPKG

@google/clasp

Version:

Develop Apps Script Projects locally

512 lines (511 loc) 24.4 kB
// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // This file manages the synchronization of files between the local filesystem // and the Google Apps Script project. It handles pulling, pushing, collecting // local files, watching for changes, and resolving file types and conflicts. import path from 'path'; import chalk from 'chalk'; import chokidar from 'chokidar'; import Debug from 'debug'; import { fdir } from 'fdir'; import fs from 'fs/promises'; import { google } from 'googleapis'; import { GaxiosError } from 'googleapis-common'; import micromatch from 'micromatch'; import normalizePath from 'normalize-path'; import pMap from 'p-map'; import { assertAuthenticated, assertScriptConfigured, handleApiError } from './utils.js'; const debug = Debug('clasp:core'); function parentDirs(file) { const parentDirs = []; let currentDir = path.dirname(file); while (currentDir !== '.') { parentDirs.push(currentDir); currentDir = path.dirname(currentDir); } return parentDirs; } async function getLocalFiles(rootDir, ignorePatterns, recursive) { debug('Collecting files in %s', rootDir); let fdirBuilder = new fdir().withBasePath().withRelativePaths(); if (!recursive) { debug('Not recursive, limiting depth to current directory'); fdirBuilder = fdirBuilder.withMaxDepth(0); // Limit crawling to the current directory if not recursive } const files = await fdirBuilder.crawl(rootDir).withPromise(); let filteredFiles; if (ignorePatterns && ignorePatterns.length) { // Filter out files that are explicitly ignored by the .claspignore file or default ignore patterns. filteredFiles = micromatch.not(files, ignorePatterns, { dot: true }); debug('Filtered %d files from ignore rules', files.length - filteredFiles.length); } else { debug('Ignore rules are empty, using all files.'); filteredFiles = files; } filteredFiles.sort((a, b) => a.localeCompare(b)); return filteredFiles[Symbol.iterator](); } async function getUnfilteredLocalFiles(rootDir) { debug('Collecting files in %s', rootDir); const fdirBuilder = new fdir().withBasePath(); const files = await fdirBuilder.crawl(rootDir).withPromise(); files.sort((a, b) => a.localeCompare(b)); return files[Symbol.iterator](); } function createFilenameConflictChecker() { const files = new Set(); return (file) => { if (file.type !== 'SERVER_JS') { return file; // Conflict check only applies to SERVER_JS files } const parsedPath = path.parse(file.localPath); // Create a key based on directory and name (without extension) to detect conflicts // e.g. `src/Code.js` and `src/Code.gs` would conflict. const key = path.format({ dir: parsedPath.dir, name: parsedPath.name }); if (files.has(key)) { throw new Error('Conflicting files found', { // TODO: Better error message, show conflicting files cause: { code: 'FILE_CONFLICT', value: key, }, }); } return file; }; } function getFileType(fileName, fileExtensions) { var _a, _b, _c; const originalExtension = path.extname(fileName); const extension = originalExtension.toLowerCase(); if ((_a = fileExtensions['SERVER_JS']) === null || _a === void 0 ? void 0 : _a.includes(extension)) { return 'SERVER_JS'; } if ((_b = fileExtensions['HTML']) === null || _b === void 0 ? void 0 : _b.includes(extension)) { return 'HTML'; } if (((_c = fileExtensions['JSON']) === null || _c === void 0 ? void 0 : _c.includes(extension)) && path.basename(fileName, originalExtension) === 'appsscript') { return 'JSON'; } return undefined; } function getFileExtension(type, fileExtensions) { // TODO - Include project setting override const extensionFor = (type, defaultValue) => { // Prioritize the first extension defined for a type in .clasp.json if available. if (fileExtensions[type] && fileExtensions[type][0]) { return fileExtensions[type][0]; } return defaultValue; // Fallback to default if no specific extension is configured. }; switch (type) { case 'SERVER_JS': return extensionFor('SERVER_JS', '.js'); // Default to .js for server-side JavaScript case 'JSON': return extensionFor('JSON', '.json'); // Default to .json for JSON files (e.g. appsscript.json) case 'HTML': return extensionFor('HTML', '.html'); // Default to .html for HTML files default: // This case should ideally not be reached if file types are correctly identified. throw new Error('Invalid file type', { cause: { code: 'INVALID_FILE_TYPE', value: type, }, }); } } function debounceFileChanges(callback, delayMs) { let timeoutId; let collectedPaths = []; return function (path) { // Already tracked as changed, ignore if (collectedPaths.includes(path)) { debug('Ignoring pending file change for path %s', path); return; } debug('Debouncing change for path %s', path); collectedPaths.push(path); clearTimeout(timeoutId); timeoutId = setTimeout(() => { debug('Firing debounced file'); callback(collectedPaths); collectedPaths = []; }, delayMs); }; } /** * Manages operations related to project files, including fetching remote files, * collecting local files based on ignore patterns, watching for local changes, * pushing local changes to the remote project, and pulling remote files * to the local filesystem. */ export class Files { /** * Constructs a new Files instance. * @param {ClaspOptions} options - Configuration options for file operations. */ constructor(options) { this.options = options; } /** * Fetches the content of a script project from Google Drive. * @param {number} [versionNumber] - Optional version number to fetch. * If not specified, the latest version (HEAD) is fetched. * @returns {Promise<ProjectFile[]>} A promise that resolves to an array of project files. * @throws {Error} If there's an API error or authentication/configuration issues. */ async fetchRemote(versionNumber) { var _a; debug('Fetching remote files, version %s', versionNumber !== null && versionNumber !== void 0 ? versionNumber : 'HEAD'); assertAuthenticated(this.options); assertScriptConfigured(this.options); const credentials = this.options.credentials; const contentDir = this.options.files.contentDir; const scriptId = this.options.project.scriptId; const script = google.script({ version: 'v1', auth: credentials }); const fileExtensionMap = this.options.files.fileExtensions; try { const requestOptions = { scriptId, versionNumber }; debug('Fetching script content, request %o', requestOptions); const response = await script.projects.getContent(requestOptions); const files = (_a = response.data.files) !== null && _a !== void 0 ? _a : []; return files.map(f => { var _a, _b, _c; const ext = getFileExtension(f.type, fileExtensionMap); const localPath = path.relative(process.cwd(), path.resolve(contentDir, `${f.name}${ext}`)); const file = { localPath: localPath, remotePath: (_a = f.name) !== null && _a !== void 0 ? _a : undefined, source: (_b = f.source) !== null && _b !== void 0 ? _b : undefined, type: (_c = f.type) !== null && _c !== void 0 ? _c : undefined, }; debug('Fetched file %O', file); return file; }); } catch (error) { handleApiError(error); } } /** * Collects all local files in the project's content directory, respecting ignore patterns. * It reads the content of each file and determines its type. * @returns {Promise<ProjectFile[]>} A promise that resolves to an array of local project files. * @throws {Error} If the project is not configured or there's a file conflict. */ async collectLocalFiles() { var _a; debug('Collecting local files'); assertScriptConfigured(this.options); const contentDir = this.options.files.contentDir; const ignorePatterns = (_a = this.options.files.ignorePatterns) !== null && _a !== void 0 ? _a : []; const recursive = !this.options.files.skipSubdirectories; // Read all filenames as a flattened tree // Note: filePaths contain relative paths such as "test/bar.ts", "../../src/foo.js" const filelist = Array.from(await getLocalFiles(contentDir, ignorePatterns, recursive)); const checkDuplicate = createFilenameConflictChecker(); const fileExtensionMap = this.options.files.fileExtensions; const files = await Promise.all(filelist.map(async (filename) => { const localPath = path.relative(process.cwd(), path.join(contentDir, filename)); const resolvedPath = path.relative(contentDir, localPath); const parsedPath = path.parse(resolvedPath); let remotePath = normalizePath(path.format({ dir: parsedPath.dir, name: parsedPath.name })); const type = getFileType(localPath, fileExtensionMap); if (!type) { debug('Ignoring unsupported file %s', localPath); return undefined; } if (type === 'JSON' && path.basename(localPath) === 'appsscript.json') { // Manifest has a fixed path in script remotePath = 'appsscript'; } const content = await fs.readFile(localPath); const source = content.toString(); return checkDuplicate({ localPath, remotePath, source, type }); })); return files.filter((f) => f !== undefined); } /** * Watches for changes in local project files and triggers callbacks. * @param {() => Promise<void> | void} onReady - Callback executed when the watcher is ready. * @param {(files: string[]) => Promise<void> | void} onFilesChanged - Callback executed * when files are added, changed, or deleted, with a debounced list of changed file paths. * @returns {() => Promise<void>} A function that can be called to stop watching. */ watchLocalFiles(onReady, onFilesChanged) { var _a; const ignorePatterns = (_a = this.options.files.ignorePatterns) !== null && _a !== void 0 ? _a : []; const collector = debounceFileChanges(onFilesChanged, 500); // Debounce changes to avoid rapid firing const onChange = async (path) => { debug('Have file changes: %s', path); collector(path); // Collect changed paths }; let matcher; if (ignorePatterns && ignorePatterns.length) { // Custom matcher function for chokidar to respect .claspignore patterns. // This is necessary because chokidar's `ignored` option expects specific formats. matcher = (file, stats) => { if (!(stats === null || stats === void 0 ? void 0 : stats.isFile())) { return false; // Only consider files for ignore matching } // Normalize file path relative to project root for consistent matching with ignorePatterns. file = path.relative(this.options.files.projectRootDir, file); // Check if the file is NOT in the list of files to keep (i.e., it should be ignored). const ignore = micromatch.not([file], ignorePatterns, { dot: true }).length === 0; return ignore; }; } const watcher = chokidar.watch(this.options.files.contentDir, { persistent: true, // Keep watching until explicitly closed ignoreInitial: true, // Don't trigger 'add' events for existing files on startup cwd: this.options.files.contentDir, // Watch paths relative to contentDir ignored: matcher, // Use custom ignore logic if patterns are present }); watcher.on('ready', onReady); // Callback when initial scan is complete watcher.on('add', onChange); // On new file addition watcher.on('change', onChange); // On file content change watcher.on('unlink', onChange); watcher.on('error', err => { debug('Unexpected error during watch: %O', err); }); return async () => { debug('Stopping watch'); await watcher.close(); }; } /** * Compares local files with remote files (HEAD version) to identify changes. * A file is considered changed if it's new locally or its content differs from the remote version. * @returns {Promise<ProjectFile[]>} A promise that resolves to an array of project files that have changed. */ async getChangedFiles() { const [localFiles, remoteFiles] = await Promise.all([this.collectLocalFiles(), this.fetchRemote()]); // Iterate over local files and compare with their remote counterparts. return localFiles.reduce((changed, localFile) => { const remote = remoteFiles.find(f => f.localPath === localFile.localPath); // A file is considered changed if it doesn't exist remotely or if its source content differs. if (!remote || remote.source !== localFile.source) { changed.push(localFile); } return changed; }, []); } /** * Identifies files present in the local content directory that are not tracked * by the Apps Script project (i.e., not in `.claspignore` or `appsscript.json`'s `filePushOrder` * and not matching supported file extensions). * @returns {Promise<string[]>} A promise that resolves to an array of untracked file paths, * collapsed to their common parent directories where applicable. */ async getUntrackedFiles() { debug('Collecting untracked files'); assertScriptConfigured(this.options); const contentDir = this.options.files.contentDir; const cwd = process.cwd(); const dirsWithIncludedFiles = new Set(); const trackedFiles = new Set(); const untrackedFiles = new Set(); const projectFiles = await this.collectLocalFiles(); for (const file of projectFiles) { debug('Found tracked file %s', file.localPath); trackedFiles.add(file.localPath); // Save all parent paths of tracked files to allow quick lookup. // This helps in collapsing untracked file paths to their nearest common untracked parent. const dirs = parentDirs(file.localPath); dirs.forEach(dir => dirsWithIncludedFiles.add(dir)); } // Get all files in the content directory without applying ignore rules yet. const allFiles = await getUnfilteredLocalFiles(contentDir); for (const file of allFiles) { const resolvedPath = path.relative(cwd, file); if (trackedFiles.has(resolvedPath)) { // If the file is already tracked (i.e., part of the project to be pushed), skip it. continue; } // Reduce path to the nearest parent directory that itself does not contain any tracked files. // This groups untracked files under their common untracked root. // For example, if 'node_modules/lib/a.js' and 'node_modules/lib/b.js' are untracked, // and 'node_modules/lib' contains no tracked files, this will report 'node_modules/lib/'. let excludedPath = resolvedPath; for (const dir of parentDirs(resolvedPath)) { if (dirsWithIncludedFiles.has(dir)) { // Stop if we reach a directory that is a parent of some tracked file. break; } excludedPath = path.normalize(`${dir}/`); // Mark as directory } debug('Found untracked file/directory %s', excludedPath); untrackedFiles.add(excludedPath); } const untrackedFilesArray = Array.from(untrackedFiles); untrackedFilesArray.sort((a, b) => a.localeCompare(b)); // Sort for consistent output return untrackedFilesArray; } /** * Pushes local project files to the Google Apps Script project. * Files are sorted according to `filePushOrder` from the manifest if specified. * Handles API errors, including syntax errors in pushed files. * @returns {Promise<ProjectFile[]>} A promise that resolves to an array of files that were pushed. * Returns an empty array if no files were found to push. * @throws {Error} If there's an API error, authentication/configuration issues, or a syntax error in the code. */ async push() { var _a; debug('Pushing files'); assertAuthenticated(this.options); assertScriptConfigured(this.options); const credentials = this.options.credentials; const scriptId = this.options.project.scriptId; const files = await this.collectLocalFiles(); if (!files || files.length === 0) { debug('No files found to push.'); return []; } const filePushOrder = (_a = this.options.files.filePushOrder) !== null && _a !== void 0 ? _a : []; files.sort((a, b) => { const indexA = filePushOrder.indexOf(a.localPath); const indexB = filePushOrder.indexOf(b.localPath); // If neither file is in the push order, sort them alphabetically. if (indexA === -1 && indexB === -1) { return a.localPath.localeCompare(b.localPath); } // If only file B is in the push order, file B comes first. if (indexA === -1) { return 1; } // If only file A is in the push order, file A comes first. if (indexB === -1) { return -1; } // If both files are in the push order, sort by their index in the push order. return indexA - indexB; }); // Prepare file objects for the Apps Script API request. try { const scriptFiles = files.map(f => ({ name: f.remotePath, type: f.type, source: f.source, })); const script = google.script({ version: 'v1', auth: credentials }); const requestOptions = { scriptId, requestBody: { files: scriptFiles, }, }; debug('Updating content, request %O', requestOptions); await script.projects.updateContent(requestOptions); return files; } catch (error) { debug(error); if (error instanceof GaxiosError) { const syntaxError = extractSyntaxError(error, files); if (syntaxError) { throw new Error(syntaxError.message, { cause: { code: 'SYNTAX_ERROR', error: error, snippet: syntaxError.snippet, }, }); } } handleApiError(error); } } /** * Checks if any files specified in the `filePushOrder` of the manifest * were not actually pushed. This can help identify misconfigurations. * @param {ProjectFile[]} pushedFiles - An array of files that were successfully pushed. * @returns {void} This method does not return a value but may have side effects (e.g. logging) if implemented. * Currently, it only calculates missing files but doesn't do anything with the result. */ checkMissingFilesFromPushOrder(pushedFiles) { var _a; const missingFiles = []; for (const path of (_a = this.options.files.filePushOrder) !== null && _a !== void 0 ? _a : []) { const wasPushed = pushedFiles.find(f => f.localPath === path); if (!wasPushed) { missingFiles.push(path); } } } /** * Fetches remote project files (optionally a specific version) and writes them * to the local filesystem, overwriting existing files. * @param {number} [version] - Optional version number to pull. If not specified, * the latest version (HEAD) is pulled. * @returns {Promise<ProjectFile[]>} A promise that resolves to an array of files that were pulled. * @throws {Error} If there's an API error or authentication/configuration issues. */ async pull(version) { debug('Pulling files'); assertAuthenticated(this.options); assertScriptConfigured(this.options); const files = await this.fetchRemote(version); await this.WriteFiles(files); return files; } async WriteFiles(files) { debug('Writing files'); const mapper = async (file) => { debug('Write file %s', path.resolve(file.localPath)); if (!file.source) { debug('Skipping empty file.'); return; } const localDirname = path.dirname(file.localPath); if (localDirname !== '.') { await fs.mkdir(localDirname, { recursive: true }); } await fs.writeFile(file.localPath, file.source); }; return await pMap(files, mapper); } } function extractSyntaxError(error, files) { var _a; let message = error.message; let snippet = ''; // Try to parse the error message for syntax error details. // Example: "Syntax error: Missing ; before statement. line: 1 file: Code" const re = /Syntax error: (.+) line: (\d+) file: (.+)/; const [, errorName, lineNum, fileName] = (_a = re.exec(error.message)) !== null && _a !== void 0 ? _a : []; if (fileName === undefined) { // If parsing fails, it's not a recognized syntax error format. return undefined; } message = `${errorName} - "${fileName}:${lineNum}"`; // Attempt to create a code snippet for the error. const contextCount = 4; // Number of lines before and after the error line to include. const errFile = files.find((x) => x.remotePath === fileName); if (!errFile || !errFile.source) { // If the source file of the error cannot be found, no snippet can be generated. return undefined; } const srcLines = errFile.source.split('\n'); const errIndex = Math.max(parseInt(lineNum) - 1, 0); // 0-based index const preIndex = Math.max(errIndex - contextCount, 0); const postIndex = Math.min(errIndex + contextCount + 1, srcLines.length); // Format the snippet with dim context lines and a bold error line. const preLines = chalk.dim(` ${srcLines.slice(preIndex, errIndex).join('\n ')}`); const errLine = chalk.bold(`⇒ ${srcLines[errIndex]}`); const postLines = chalk.dim(` ${srcLines.slice(errIndex + 1, postIndex).join('\n ')}`); snippet = preLines + '\n' + errLine + '\n' + postLines; return { message, snippet }; // Return the formatted message and snippet. }