UNPKG

@google/clasp

Version:

Develop Apps Script Projects locally

409 lines (408 loc) 16.6 kB
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); } const files = await fdirBuilder.crawl(rootDir).withPromise(); let filteredFiles; if (ignorePatterns && ignorePatterns.length) { 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; } const parsedPath = path.parse(file.localPath); const key = path.format({ dir: parsedPath.dir, name: parsedPath.name }); if (files.has(key)) { throw new Error('Conflicting files found', { 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) => { if (fileExtensions[type] && fileExtensions[type][0]) { return fileExtensions[type][0]; } return defaultValue; }; switch (type) { case 'SERVER_JS': return extensionFor('SERVER_JS', '.js'); case 'JSON': return extensionFor('JSON', '.json'); case 'HTML': return extensionFor('HTML', '.html'); default: 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); }; } export class Files { constructor(options) { this.options = options; } 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); } } 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); } watchLocalFiles(onReady, onFilesChanged) { var _a; const ignorePatterns = (_a = this.options.files.ignorePatterns) !== null && _a !== void 0 ? _a : []; const collector = debounceFileChanges(onFilesChanged, 500); const onChange = async (path) => { debug('Have file changes: %s', path); collector(path); }; let matcher; if (ignorePatterns && ignorePatterns.length) { matcher = (file, stats) => { if (!(stats === null || stats === void 0 ? void 0 : stats.isFile())) { return false; } file = path.relative(this.options.files.projectRootDir, file); const ignore = micromatch.not([file], ignorePatterns, { dot: true }).length === 0; return ignore; }; } const watcher = chokidar.watch(this.options.files.contentDir, { persistent: true, ignoreInitial: true, cwd: this.options.files.contentDir, ignored: matcher, }); watcher.on('ready', onReady); // Push on start watcher.on('add', onChange); watcher.on('change', onChange); watcher.on('unlink', onChange); watcher.on('error', err => { debug('Unexpected error during watch: %O', err); }); return async () => { debug('Stopping watch'); await watcher.close(); }; } async getChangedFiles() { const [localFiles, remoteFiles] = await Promise.all([this.collectLocalFiles(), this.fetchRemote()]); return localFiles.reduce((changed, localFile) => { const remote = remoteFiles.find(f => f.localPath === localFile.localPath); if (!remote || remote.source !== localFile.source) { changed.push(localFile); } return changed; }, []); } 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 to allow quick lookup. // Allows collapsing the unfiltered files to the common parent directory const dirs = parentDirs(file.localPath); dirs.forEach(dir => dirsWithIncludedFiles.add(dir)); } const allFiles = await getUnfilteredLocalFiles(contentDir); for (const file of allFiles) { const resolvedPath = path.relative(cwd, file); if (trackedFiles.has(resolvedPath)) { // Tracked file, skip continue; } // Reduce path to nearest parent directory with no project files included let excludedPath = resolvedPath; for (const dir of parentDirs(resolvedPath)) { if (dirsWithIncludedFiles.has(dir)) { break; } excludedPath = path.normalize(`${dir}/`); } debug('Found untracked file %s', excludedPath); untrackedFiles.add(excludedPath); } const untrackedFilesArray = Array.from(untrackedFiles); untrackedFilesArray.sort((a, b) => a.localeCompare(b)); return untrackedFilesArray; } 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 (indexA === -1 && indexB === -1) { // Neither has explicit order, sort by name return a.localPath.localeCompare(b.localPath); } if (indexA === -1) { // B has explicit priority, is first return 1; } if (indexB === -1) { // A has explicit priority, is first return -1; } // Both prioritized, use rank return indexA - indexB; }); // Start pushing. 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); } } 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); } } } 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 = ''; const re = /Syntax error: (.+) line: (\d+) file: (.+)/; const [, errorName, lineNum, fileName] = (_a = re.exec(error.message)) !== null && _a !== void 0 ? _a : []; if (fileName === undefined) { return undefined; } message = `${errorName} - "${fileName}:${lineNum}"`; // Get formatted code snippet const contextCount = 4; const errFile = files.find((x) => x.remotePath === fileName); if (!errFile || !errFile.source) { return undefined; } const srcLines = errFile.source.split('\n'); const errIndex = Math.max(parseInt(lineNum) - 1, 0); const preIndex = Math.max(errIndex - contextCount, 0); const postIndex = Math.min(errIndex + contextCount + 1, srcLines.length); 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 }; }