google-apps-scriptclasp

How to develop a Google Apps Script Web App with different stages / different GSuite accounts?


I was wondering how to have different stages (dev. prod) when developing a Google Apps Script webapp. Afaik there is nothing out of the box, especially when you want to have one G Suite Account for dev and another for prod.

The only solution how to handle this, I came up with, is to use Clasp and switch between two GSuite Accounts with clasp logout / login

My questions are:

I think it is better to explain it on an example:

I have a Google App Script project setup with clasp on domain test.mydomain.com with the user user@test.mydomain.com

Now I need to push this with clasp to another project (same name) on a different domain prod.mydomain.com. This project is shared with another user where I have the credentials.

The problem is when I now login with the user in clasp I would need to create a project first right? But the project already exists. So how can I handle that?


Solution

  • Does this work?

    Yes, just make sure the account has edit access to be able to push (but I am sure you know that).

    Is there a better approach?

    There sort of is (unless you implicitly meant it), to quote from issue #42 of the clasp project GitHub:

    New flag:

    clasp login --local Saves .clasprc.json locally, but would use global clasp credentials.

    clasp login # global for all projects
    clasp login --creds creds.json # your own creds
    clasp login --local # local project. Useful for multiple Google accounts.
    clasp login --creds creds.json --local # own creds, local

    So technically you can use multiple accounts, but in the end, it boils down to your login / logout technique.


    Regrading discussion on switching between apps script projects with CLASP, I ended up writing a basic utility script for switching between Apps Script projects to push to (no dependencies, but if you want to manage flags in style, check out the popular Yargs package):

    const fs = require('fs').promises;
    const { promisify } = require("util");
    const { spawn } = require("child_process");
    
    const rl = require("readline");
    
    const promiseAns = () => {
        const dummy = rl.createInterface({
            input: process.stdin
        });
    
        dummy.question[promisify.custom] = function (query) {
            return new Promise((resolve) => this.question( query, resolve));
        };
    
        return promisify(dummy.question);
    };
    
    
    
    /**
     * @summary asks to confirm and exits if ok
     * @param {import("readline").Interface} cons 
     * @param {string} init
     */
    const checkExit = async (cons, init) =>{ 
    
        if ( !/exit/.test(init) ) {
            return;
        }
    
        const question = promiseAns();
    
        const ans = await question.bind(cons)(`Are you sure (Y|n)?\n`);
    
        if (/^Y(?:es)?/i.test(ans)) {
            process.exit();
        }
    }
    
    /**
     * @summary repeat question until matches
     * @param {import("readline").Interface} cons 
     * @param {string} query 
     * @param {(ans: string) => boolean} condition 
     * @param {(ans: string) => any} onSuccess 
     */
    const askUntil = (cons, query, condition, onSuccess) => cons.question(query, async (ans) => {
    
        await checkExit(cons, ans);
    
        if (!condition(ans)) {
            return askUntil(cons, query, condition, onSuccess);
        }
    
        onSuccess(ans);
    });
    
    /**
     * @summary makes readline interface
     */
    const makeRL = () => {
    
        const cons = rl.createInterface({
            input: process.stdin,
            output: process.stdout,
        });
    
        cons.on("line", (ln) => console.log(ln));
    
        return cons;
    };
    
    process.on("unhandledRejection", (err) => {
        console.log(err);
        process.exit();
    });
    
    const APP_CONFIG = {
    
        paths: {
    
            dotclasp: "./.clasp.json",
    
            ids: "./ids.txt"
    
        }
    
    };
    
    (async (configuration) => {
    
        const cons = makeRL();
    
        const { paths: { dotclasp, ids } } = configuration;
    
        const text = await fs.readFile(ids, { encoding: "utf-8" });
    
        const lines = text.split(/\r?\n/);
    
        const relevant = lines.filter((line) => /^(\w+)\s+\w+/.test(line));
    
        const lookup = {};
    
        relevant.forEach((lbl) => {
            const [label, id] = lbl.split(/\s+/);
            lookup[label] = id;
        });
    
        const config = require(dotclasp);
    
        const [label] = Object.entries(lookup).find(([, id]) => config.scriptId === id);
    
        cons.emit("line", `Currently selected: ${label}`);
    
        const { argv } = process;
    
        const push = argv.includes("--push");
    
        askUntil(cons, `Select project (${Object.keys(lookup).join(" | ")})\n`, (ans) => lookup[ans], async (ans) => {
    
            config.scriptId = lookup[ans];
    
            try {
                await fs.writeFile(dotclasp, JSON.stringify(config), { encoding: "utf-8" });
                cons.emit("line", `switched to ${ans}`);
            }
            catch {
                cons.emit("line", `failed to switch to ${ans}`);
            }
    
            if (!push) {
                process.exit();
            }
    
            const cp = spawn(`clasp push --force`, {
                stdio: "inherit",
                shell: true
            });
    
            cp.on("error", ({ message }) => {
                cons.write(message);
                process.exit();
            });
    
            cp.on("exit", () => {
                cons.emit("line", `pushed to ${ans}`);
                process.exit();
            });
    
        });
    
    })(APP_CONFIG);
    

    sample run