UNPKG

@google/clasp

Version:

Develop Apps Script Projects locally

286 lines (285 loc) 11.6 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 defines the main `Clasp` class, which orchestrates all core // functionalities of the CLI, including configuration management, API // interactions, and file operations. import path from 'path'; import Debug from 'debug'; import { findUpSync } from 'find-up'; import fs from 'fs/promises'; import splitLines from 'split-lines'; import stripBom from 'strip-bom'; import { getUserInfo } from '../auth/auth.js'; import { Files } from './files.js'; import { Functions } from './functions.js'; import { Logs } from './logs.js'; import { Project } from './project.js'; import { Services } from './services.js'; import { ensureStringArray } from './utils.js'; const debug = Debug('clasp:core'); const DEFAULT_CLASP_IGNORE = [ '**/**', '!**/appsscript.json', '!**/*.gs', '!**/*.js', '!**/*.ts', '!**/*.html', '.git/**', 'node_modules/**', ]; /** * Main class for interacting with Google Apps Script projects. * It encapsulates all core functionalities like file management, * project settings, API interactions, and authentication. */ export class Clasp { /** * Creates an instance of the Clasp class. * @param {ClaspOptions} options - Configuration options for the Clasp instance. */ constructor(options) { debug('Creating clasp instance with options: %O', options); this.options = options; this.services = new Services(options); this.files = new Files(options); this.project = new Project(options); this.logs = new Logs(options); this.functions = new Functions(options); } async authorizedUser() { if (!this.options.credentials) { return undefined; } try { const user = await getUserInfo(this.options.credentials); return user === null || user === void 0 ? void 0 : user.id; } catch (err) { debug('Unable to fetch user info, %O', err); } return undefined; } /** * Configures the Clasp instance with a specific Apps Script project ID. * This is a fluent method and returns the `Clasp` instance for chaining. * @param {string} scriptId - The ID of the Apps Script project. * @returns {this} The current Clasp instance. * @throws {Error} If the project is already set. */ withScriptId(scriptId) { if (this.options.project) { debug('Project is already configured, overriding scriptId with %s', scriptId); } this.options.project = { scriptId, }; return this; } /** * Sets the content directory for the project files. * This directory is where clasp looks for source files (e.g., `.js`, `.html`). * If a relative path is provided, it's resolved against the project's root directory. * This is a fluent method and returns the `Clasp` instance for chaining. * @param {string} contentDir - The path to the content directory. * @returns {this} The current Clasp instance. */ withContentDir(contentDir) { if (!path.isAbsolute(contentDir)) { contentDir = path.resolve(this.options.files.projectRootDir, contentDir); } this.options.files.contentDir = contentDir; return this; } } /** * Initializes and returns a Clasp instance. * This function searches for project configuration files (`.clasp.json`, `.claspignore`), * loads them if found, or sets up default configurations if not. It then creates * and returns a new `Clasp` object configured with these settings. * @param {InitOptions} options - Options for initializing the Clasp instance. * @returns {Promise<Clasp>} A promise that resolves to a configured Clasp instance. */ export async function initClaspInstance(options) { var _a; debug('Initializing clasp instance'); // Attempt to find the project root directory and .clasp.json config file. const projectRoot = await findProjectRootdDir(options.configFile); // If no .clasp.json is found, set up a default Clasp instance. if (!projectRoot) { // Use the provided rootDir option or default to the current working directory. const dir = (_a = options.rootDir) !== null && _a !== void 0 ? _a : process.cwd(); debug(`No project found, defaulting to ${dir}`); const rootDir = path.resolve(dir); // Default path for .clasp.json if one were to be created. const configFilePath = path.resolve(rootDir, '.clasp.json'); const ignoreFile = await findIgnoreFile(rootDir, options.ignoreFile); const ignoreRules = await loadIgnoreFileOrDefaults(ignoreFile); // Create a Clasp instance with default file settings and no project-specific config. return new Clasp({ credentials: options.credentials, configFilePath, // Path where .clasp.json would be. files: { projectRootDir: rootDir, contentDir: rootDir, // By default, content directory is the root directory. ignoreFilePath: ignoreFile, ignorePatterns: ignoreRules, filePushOrder: [], // No specific push order. skipSubdirectories: false, // Process subdirectories by default. fileExtensions: readFileExtensions({}), // Default file extensions. }, // No project options (scriptId, projectId, parentId) as .clasp.json was not found. }); } // If .clasp.json is found, load its configuration. debug('Project config found at %s', projectRoot.configPath); const ignoreFile = await findIgnoreFile(projectRoot.rootDir, options.ignoreFile); const ignoreRules = await loadIgnoreFileOrDefaults(ignoreFile); const content = await fs.readFile(projectRoot.configPath, { encoding: 'utf8' }); const config = JSON.parse(content); // Parse the JSON content of .clasp.json. // Determine file extensions, push order, and content directory from the loaded config. const fileExtensions = readFileExtensions(config); const filePushOrder = config.filePushOrder || []; // Default to empty array if not specified. // Content directory can be specified by `srcDir` or `rootDir` in .clasp.json, defaulting to project root. const contentDir = path.resolve(projectRoot.rootDir, config.srcDir || config.rootDir || '.'); return new Clasp({ credentials: options.credentials, configFilePath: projectRoot.configPath, files: { projectRootDir: projectRoot.rootDir, contentDir: contentDir, ignoreFilePath: ignoreFile, ignorePatterns: ignoreRules, filePushOrder: filePushOrder, fileExtensions: fileExtensions, skipSubdirectories: config.ignoreSubdirectories, }, project: { scriptId: config.scriptId, projectId: config.projectId, parentId: firstValue(config.parentId), }, }); } function readFileExtensions(config) { let scriptExtensions = ['js', 'gs']; // Default script file extensions. let htmlExtensions = ['html']; // Default HTML file extensions. let jsonExtensions = ['json']; // Default JSON file extensions (primarily for appsscript.json). // Support for legacy `fileExtension` setting (singular). if (config === null || config === void 0 ? void 0 : config.fileExtension) { scriptExtensions = [config.fileExtension]; } // Support for current `scriptExtensions` setting (plural, array). if (config === null || config === void 0 ? void 0 : config.scriptExtensions) { scriptExtensions = ensureStringArray(config.scriptExtensions); } if (config === null || config === void 0 ? void 0 : config.htmlExtensions) { htmlExtensions = ensureStringArray(config.htmlExtensions); } if (config === null || config === void 0 ? void 0 : config.jsonExtensions) { jsonExtensions = ensureStringArray(config.jsonExtensions); } // Ensure all extensions are lowercase and start with a dot. const fixupExtension = (ext) => { ext = ext.toLowerCase().trim(); if (!ext.startsWith('.')) { ext = `.${ext}`; } return ext; }; return { SERVER_JS: scriptExtensions.map(fixupExtension), HTML: htmlExtensions.map(fixupExtension), JSON: jsonExtensions.map(fixupExtension), }; } async function findProjectRootdDir(configFilePath) { debug('Searching for project root'); if (configFilePath) { debug('Checking for config file at %s', configFilePath); const info = await fs.stat(configFilePath); if (info.isDirectory()) { debug('Is directory, trying file'); configFilePath = path.join(configFilePath, '.clasp.json'); } } else { debug('Searching parent paths for .clasp.json'); configFilePath = findUpSync('.clasp.json'); } if (!configFilePath) { debug('No project found'); return undefined; } const configFileExists = await hasReadAccess(configFilePath); if (!configFileExists) { debug('Project file %s does not exist', configFilePath); return undefined; } debug('Project found at %s', configFilePath); const rootDir = path.dirname(configFilePath); return { rootDir, configPath: configFilePath, }; } async function findIgnoreFile(projectDir, configFilePath) { debug('Searching for ignore file'); if (configFilePath) { debug('Checking for ignore file at %s', configFilePath); const info = await fs.stat(configFilePath); if (info.isDirectory()) { debug('Is directory, trying file'); configFilePath = path.join(configFilePath, '.claspignore'); } } else { debug('Checking default location'); configFilePath = path.join(projectDir, '.claspignore'); } if (!configFilePath) { debug('No ignore file found'); return undefined; } const configFileExists = await hasReadAccess(configFilePath); if (!configFileExists) { debug('ignore file %s does not exist', configFilePath); return undefined; } debug('Ignore file found at %s', configFilePath); return configFilePath; } async function loadIgnoreFileOrDefaults(configPath) { if (!configPath) { debug('Using default file ignore rules'); return DEFAULT_CLASP_IGNORE; } let content = await fs.readFile(configPath, { encoding: 'utf8' }); content = stripBom(content); return splitLines(content).filter((name) => name.length > 0); } async function hasReadAccess(path) { try { await fs.access(path, fs.constants.R_OK); } catch { return false; } return true; } function firstValue(values) { if (Array.isArray(values) && values.length > 0) { return values[0]; } return values; }