UNPKG

@google/clasp

Version:

Develop Apps Script Projects locally

465 lines (464 loc) 21.7 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 `Project` class, which is responsible for managing // Google Apps Script project metadata, lifecycle operations (creation, versions, // deployments), and local project configuration settings. import Debug from 'debug'; import fs from 'fs/promises'; import { google } from 'googleapis'; import { fetchWithPages } from './utils.js'; import { assertAuthenticated, assertScriptConfigured, handleApiError } from './utils.js'; import path from 'path'; const debug = Debug('clasp:core'); /** * Manages Google Apps Script project settings and interactions with the * Apps Script API for operations like creating projects, versions, * and deployments. It also handles reading and writing the local * `.clasp.json` configuration file and the `appsscript.json` manifest. */ export class Project { constructor(options) { this.options = options; } get scriptId() { var _a; return (_a = this.options.project) === null || _a === void 0 ? void 0 : _a.scriptId; } get projectId() { var _a; return (_a = this.options.project) === null || _a === void 0 ? void 0 : _a.projectId; } get parentId() { var _a; return (_a = this.options.project) === null || _a === void 0 ? void 0 : _a.parentId; } // TODO - Do we need the assertion or can just use accessor? /** * Retrieves the Google Cloud Platform (GCP) project ID associated with the script. * Asserts that the script is configured before returning the ID. * @returns {string | undefined} The GCP project ID, or undefined if not set. * @throws {Error} If the script is not configured. */ getProjectId() { assertScriptConfigured(this.options); return this.options.project.projectId; } /** * Creates a new standalone Apps Script project. * @param {string} name - The title for the new script project. * @param {string} [parentId] - Optional ID of a Google Drive folder to create the script in. * @returns {Promise<string>} A promise that resolves to the script ID of the newly created project. * @throws {Error} If there's an API error or authentication issues. */ async createScript(name, parentId) { var _a; debug('Creating script %s', name); assertAuthenticated(this.options); if ((_a = this.options.project) === null || _a === void 0 ? void 0 : _a.scriptId) { debug('Warning: Creating script while id already exists'); } const credentials = this.options.credentials; const script = google.script({ version: 'v1', auth: credentials }); try { const requestOptions = { requestBody: { parentId, title: name, }, }; debug('Creating project with request %O', requestOptions); const res = await script.projects.create(requestOptions); if (!res.data.scriptId) { throw new Error('Unexpected error, script ID missing from response.'); } debug('Created script %s', res.data.scriptId); const scriptId = res.data.scriptId; this.options.project = { scriptId, parentId }; return scriptId; } catch (error) { handleApiError(error); } } /** * Moves the specified Google Drive file to the trash. * @returns {Promise<void>} A promise that resolves when the file is successfully trashed. */ async trashScript() { var _a; debug('Deleting script %s', (_a = this.options.project) === null || _a === void 0 ? void 0 : _a.scriptId); assertAuthenticated(this.options); assertScriptConfigured(this.options); const fileId = this.options.project.scriptId; const credentials = this.options.credentials; const drive = google.drive({ version: 'v3', auth: credentials }); try { const requestOptions = { fileId, requestBody: { trashed: true, }, }; debug('Trashing script with request %O', requestOptions); await drive.files.update(requestOptions); } catch (error) { handleApiError(error); } } /** * Creates a new Google Drive file (e.g., Sheet, Doc) and a bound Apps Script project for it. * @param {string} name - The title for the new Drive file and script project. * @param {string} mimeType - The MIME type of the Drive file to create (e.g., 'application/vnd.google-apps.spreadsheet'). * @returns {Promise<{scriptId: string; parentId: string}>} A promise that resolves to an object * containing the script ID and the parent Drive file ID. * @throws {Error} If there's an API error or authentication issues. */ async createWithContainer(name, mimeType) { var _a; debug('Creating container bound script %s (%s)', name, mimeType); assertAuthenticated(this.options); if ((_a = this.options.project) === null || _a === void 0 ? void 0 : _a.scriptId) { debug('Warning: Creating script while id already exists'); } let parentId; const credentials = this.options.credentials; const drive = google.drive({ version: 'v3', auth: credentials }); // Create the container file (e.g., Google Sheet, Doc) using the Drive API. try { const requestOptions = { requestBody: { mimeType, name, }, }; debug('Creating project with request %O', requestOptions); const res = await drive.files.create(requestOptions); parentId = res.data.id; // Get the ID of the newly created container file. debug('Created container %s', parentId); if (!parentId) { throw new Error('Unexpected error, container ID missing from response.'); } } catch (error) { handleApiError(error); } // Once the container is created, create an Apps Script project bound to it. const scriptId = await this.createScript(name, parentId); return { parentId, // Return the ID of the container. scriptId, }; } /** * Lists Apps Script projects accessible by the authenticated user from Google Drive. * @returns {Promise<{results: Script[], partialResults: boolean} | undefined>} * A promise that resolves to an object containing an array of script projects * (with name and ID) and a flag indicating if results are partial, or undefined on error. * @throws {Error} If there's an API error or authentication issues. */ async listScripts() { debug('Fetching scripts'); assertAuthenticated(this.options); const credentials = this.options.credentials; const drive = google.drive({ version: 'v3', auth: credentials }); try { return fetchWithPages(async (pageSize, pageToken) => { var _a, _b; const requestOptions = { pageSize, pageToken, fields: 'nextPageToken, files(id, name)', q: 'mimeType="application/vnd.google-apps.script"', }; debug('Fetching scripts from drive with request %O', requestOptions); const res = await drive.files.list(requestOptions); return { results: ((_a = res.data.files) !== null && _a !== void 0 ? _a : []), pageToken: (_b = res.data.nextPageToken) !== null && _b !== void 0 ? _b : undefined, }; }); } catch (error) { handleApiError(error); } } /** * Creates a new immutable version of the Apps Script project. * @param {string} [description=''] - An optional description for the new version. * @returns {Promise<number>} A promise that resolves to the newly created version number. * @throws {Error} If there's an API error or authentication/configuration issues. */ async version(description = '') { var _a; debug('Creating version: %s', description); assertAuthenticated(this.options); assertScriptConfigured(this.options); const credentials = this.options.credentials; const scriptId = this.options.project.scriptId; const script = google.script({ version: 'v1', auth: credentials }); try { const requestOptions = { requestBody: { description: description !== null && description !== void 0 ? description : '', }, scriptId: scriptId, }; debug('Creating version with request %O', requestOptions); const res = await script.projects.versions.create(requestOptions); const versionNumber = (_a = res.data.versionNumber) !== null && _a !== void 0 ? _a : 0; debug('Created new version %d', versionNumber); return versionNumber; } catch (error) { handleApiError(error); } } /** * Lists all immutable versions of the Apps Script project. * @returns {Promise<{results: script_v1.Schema$Version[], partialResults: boolean} | undefined>} * A promise that resolves to an object containing an array of version objects * and a flag indicating if results are partial, or undefined on error. * @throws {Error} If there's an API error or authentication/configuration issues. */ async listVersions() { debug('Fetching versions'); assertAuthenticated(this.options); assertScriptConfigured(this.options); const scriptId = this.options.project.scriptId; const credentials = this.options.credentials; const script = google.script({ version: 'v1', auth: credentials }); try { return fetchWithPages(async (pageSize, pageToken) => { var _a, _b; const requestOptions = { scriptId, pageSize, pageToken, }; debug('Fetching versions with request %O', requestOptions); const res = await script.projects.versions.list(requestOptions); return { results: (_a = res.data.versions) !== null && _a !== void 0 ? _a : [], pageToken: (_b = res.data.nextPageToken) !== null && _b !== void 0 ? _b : undefined, }; }); } catch (error) { handleApiError(error); } } /** * Lists all deployments for the Apps Script project. * @returns {Promise<{results: script_v1.Schema$Deployment[], partialResults: boolean} | undefined>} * A promise that resolves to an object containing an array of deployment objects * and a flag indicating if results are partial, or undefined on error. * @throws {Error} If there's an API error or authentication/configuration issues. */ async listDeployments() { debug('Listing deployments'); assertAuthenticated(this.options); assertScriptConfigured(this.options); const scriptId = this.options.project.scriptId; const credentials = this.options.credentials; const script = google.script({ version: 'v1', auth: credentials }); try { return fetchWithPages(async (pageSize, pageToken) => { var _a, _b; const requestOptions = { scriptId, pageSize, pageToken, }; debug('Fetching deployments with request %O', requestOptions); const res = await script.projects.deployments.list(requestOptions); return { results: (_a = res.data.deployments) !== null && _a !== void 0 ? _a : [], pageToken: (_b = res.data.nextPageToken) !== null && _b !== void 0 ? _b : undefined, }; }); } catch (error) { handleApiError(error); } } /** * Creates a new deployment or updates an existing one for the Apps Script project. * If `versionNumber` is not provided, a new script version is created with the given `description`. * @param {string} [description=''] - Description for the new version (if created) or deployment. * @param {string} [deploymentId] - Optional ID of an existing deployment to update. If not provided, a new deployment is created. * @param {number} [versionNumber] - Optional specific script version number to deploy. * @returns {Promise<script_v1.Schema$Deployment>} A promise that resolves to the deployment object. * @throws {Error} If there's an API error or authentication/configuration issues. */ async deploy(description = '', deploymentId, versionNumber) { debug('Deploying project: %s (%s)', description, versionNumber !== null && versionNumber !== void 0 ? versionNumber : 'HEAD'); assertAuthenticated(this.options); assertScriptConfigured(this.options); // If no specific versionNumber is provided for deployment, // create a new version of the script with the given description. if (versionNumber === undefined) { versionNumber = await this.version(description); } const scriptId = this.options.project.scriptId; const credentials = this.options.credentials; const script = google.script({ version: 'v1', auth: credentials }); try { let deployment; // If no deploymentId is provided, create a new deployment. if (!deploymentId) { const requestOptions = { scriptId: scriptId, // The scriptId must be provided in the request body for create. requestBody: { description: description !== null && description !== void 0 ? description : '', versionNumber: versionNumber, manifestFileName: 'appsscript', }, }; debug('Creating deployment with request %O', requestOptions); const res = await script.projects.deployments.create(requestOptions); deployment = res.data; } else { // If a deploymentId is provided, update the existing deployment. const requestOptions = { scriptId: scriptId, // Path parameter for the scriptId. deploymentId: deploymentId, // Path parameter for the deploymentId to update. requestBody: { deploymentConfig: { description: description !== null && description !== void 0 ? description : '', versionNumber: versionNumber, scriptId: scriptId, // The scriptId also needs to be in the deploymentConfig. manifestFileName: 'appsscript', }, }, }; debug('Updating existing deployment with request %O', requestOptions); const res = await script.projects.deployments.update(requestOptions); deployment = res.data; } return deployment; // Return the created or updated deployment object. } catch (error) { handleApiError(error); } } /** * Retrieves the entry points for a specific deployment of the Apps Script project. * Entry points define how the script can be executed (e.g., as a web app, API executable). * @param {string} deploymentId - The ID of the deployment. * @returns {Promise<script_v1.Schema$EntryPoint[] | undefined>} A promise that resolves to an array * of entry point objects, or undefined if an error occurs. * @throws {Error} If there's an API error or authentication/configuration issues. */ async entryPoints(deploymentId) { var _a, _b; assertAuthenticated(this.options); assertScriptConfigured(this.options); const scriptId = this.options.project.scriptId; const credentials = this.options.credentials; const script = google.script({ version: 'v1', auth: credentials }); try { const res = await script.projects.deployments.get({ scriptId, deploymentId }); const entryPoints = (_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.entryPoints) !== null && _b !== void 0 ? _b : []; return entryPoints; } catch (error) { handleApiError(error); } } /** * Deletes a specific deployment of the Apps Script project. * @param {string} deploymentId - The ID of the deployment to delete. * @returns {Promise<void>} A promise that resolves when the deployment is deleted. * @throws {Error} If there's an API error or authentication/configuration issues. */ async undeploy(deploymentId) { debug('Deleting deployment %s', deploymentId); assertAuthenticated(this.options); assertScriptConfigured(this.options); const scriptId = this.options.project.scriptId; const credentials = this.options.credentials; const script = google.script({ version: 'v1', auth: credentials }); try { const requestOptions = { scriptId: scriptId, deploymentId, }; debug('Deleting deployment with request %O', requestOptions); await script.projects.deployments.delete(requestOptions); } catch (error) { handleApiError(error); } } /** * Writes the current project settings (script ID, root directory, parent ID, project ID, * file extensions, push order, skip subdirectories) to the `.clasp.json` file. * @returns {Promise<void>} A promise that resolves when the settings are written. * @throws {Error} If the script is not configured or there's a file system error. */ async updateSettings() { debug('Updating settings'); assertScriptConfigured(this.options); const srcDir = path.relative(this.options.files.projectRootDir, this.options.files.contentDir); const settings = { scriptId: this.options.project.scriptId, rootDir: srcDir, parentId: this.options.project.parentId, projectId: this.options.project.projectId, scriptExtensions: this.options.files.fileExtensions['SERVER_JS'], htmlExtensions: this.options.files.fileExtensions['HTML'], jsonExtensions: this.options.files.fileExtensions['JSON'], filePushOrder: [], skipSubdirectories: this.options.files.skipSubdirectories, }; await fs.writeFile(this.options.configFilePath, JSON.stringify(settings, null, 2)); } /** * Sets the Google Cloud Platform (GCP) project ID for the current Apps Script project * and updates the `.clasp.json` file. * @param {string | undefined} projectId - The GCP project ID to set. * @returns {Promise<void>} A promise that resolves when the project ID is set and settings are updated. * @throws {Error} If the script is not configured. */ async setProjectId(projectId) { debug('Setting project ID %s in file %s', projectId, this.options.configFilePath); assertScriptConfigured(this.options); this.options.project.projectId = projectId; this.updateSettings(); } /** * Checks if a script project is currently configured (i.e., if a script ID is set). * @returns {boolean} True if a script ID is set, false otherwise. */ exists() { var _a; return ((_a = this.options.project) === null || _a === void 0 ? void 0 : _a.scriptId) !== undefined; } /** * Reads and parses the `appsscript.json` manifest file from the project's content directory. * @returns {Promise<Manifest>} A promise that resolves to the parsed manifest object. * @throws {Error} If the script is not configured or the manifest file cannot be read/parsed. */ async readManifest() { debug('Reading manifest'); assertScriptConfigured(this.options); const manifestPath = path.join(this.options.files.contentDir, 'appsscript.json'); debug('Manifest path is %s', manifestPath); const content = await fs.readFile(manifestPath); const manifest = JSON.parse(content.toString()); return manifest; } }