├── .husky ├── .gitignore └── pre-commit ├── app └── assets │ └── .gitkeep ├── .clasp.json ├── .env.example ├── assets └── images │ └── project-configuration.png ├── tsconfig.build.json ├── .claspignore ├── src ├── core │ └── environment │ │ ├── environment.type.ts │ │ ├── environment.service.ts │ │ └── environment.service.spec.ts ├── features │ ├── hello │ │ ├── hello.service.ts │ │ └── hello.service.spec.ts │ ├── bye │ │ ├── bye.service.ts │ │ └── bye.service.spec.ts │ └── greeting │ │ ├── greeting.service.ts │ │ └── greeting.service.spec.ts └── index.ts ├── appsscript.json ├── biome.jsonc ├── babel.config.js ├── .vscode └── settings.json ├── test └── env.setup.js ├── jest.config.js ├── tsconfig.json ├── rollup.config.js ├── package.json ├── .gitignore └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /app/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | Put your assets HTML or JavaScript files here. -------------------------------------------------------------------------------- /.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "YOUR_SCRIPT_ID", 3 | "rootDir": "." 4 | } 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MY_SECRET_VALUE=This will be an awesome project 2 | MY_SECRET_NUMBER=99987 3 | -------------------------------------------------------------------------------- /assets/images/project-configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristobalgvera/ez-clasp/HEAD/assets/images/project-configuration.png -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmitOnError": true 5 | }, 6 | "exclude": ["node_modules", "test", "**/*.spec.ts", "**/*.test.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /.claspignore: -------------------------------------------------------------------------------- 1 | # ignore all files… 2 | **/** 3 | 4 | # except the extensions… 5 | !appsscript.json 6 | !build/**/*.js 7 | !app/** 8 | 9 | # ignore even valid files if in… 10 | .git/** 11 | node_modules/** 12 | -------------------------------------------------------------------------------- /src/core/environment/environment.type.ts: -------------------------------------------------------------------------------- 1 | /** biome-ignore-all lint/style/useNamingConvention: This file represents environment variables names */ 2 | 3 | export type Environment = { 4 | MY_SECRET_VALUE: string; 5 | MY_SECRET_NUMBER: number; 6 | }; 7 | -------------------------------------------------------------------------------- /appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/Santiago", 3 | "dependencies": { 4 | "enabledAdvancedServices": [], 5 | "libraries": [] 6 | }, 7 | "exceptionLogging": "STACKDRIVER", 8 | "runtimeVersion": "V8", 9 | "oauthScopes": [] 10 | } 11 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "extends": ["ultracite"], 4 | "linter": { 5 | "rules": { 6 | "correctness": { 7 | "noUndeclaredVariables": "off" // Required to use globals like `SpreadsheetApp` in Google Apps Script 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | ["@babel/preset-typescript"], 5 | ], 6 | plugins: [ 7 | // Polyfills the runtime needed for async/await, generators, and friends 8 | // https://babeljs.io/docs/en/babel-plugin-transform-runtime 9 | ["@babel/plugin-transform-runtime"], 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /src/features/hello/hello.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functional approach 3 | */ 4 | export const HelloService = { 5 | sayHi: (name?: string): string => `Hello, ${name ?? "World"}!`, 6 | sayHiAsync: (name?: string): Promise => { 7 | const timeToWait = 100; 8 | 9 | return new Promise((resolve) => { 10 | setTimeout(() => resolve(`Hello, ${name ?? "World"}!`), timeToWait); 11 | }); 12 | }, 13 | } as const; 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnPaste": true, 3 | "editor.formatOnSave": true, 4 | "json.schemas": [ 5 | { 6 | "fileMatch": ["appsscript.json"], 7 | "url": "http://json.schemastore.org/appsscript" 8 | }, 9 | { 10 | "fileMatch": [".clasp.json"], 11 | "url": "http://json.schemastore.org/clasp" 12 | }, 13 | { 14 | "fileMatch": ["tsconfig.json"], 15 | "url": "http://json.schemastore.org/tsconfig" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/features/bye/bye.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class approach 3 | */ 4 | export class ByeService { 5 | sayBye(name: string): string { 6 | return ByeService.sayBye(name); 7 | } 8 | 9 | static sayBye(name?: string): string { 10 | return `Bye, ${name ?? "World"}!`; 11 | } 12 | 13 | static sayByeAsync(name?: string): Promise { 14 | const timeToWait = 100; 15 | 16 | return new Promise((resolve) => { 17 | setTimeout(() => { 18 | resolve(ByeService.sayBye(name)); 19 | }, timeToWait); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/env.setup.js: -------------------------------------------------------------------------------- 1 | /** biome-ignore-all lint/style/noMagicNumbers: Test-related file */ 2 | 3 | /* 4 | * This file should contain any environment variables 5 | * that are explicitly required, e.g. variable that 6 | * you can't set a default value for using nullish 7 | * operator (??). 8 | * 9 | * Example: 10 | * process.env.SOME_REQUIRED_ENV_VAR = 'some custom value' 11 | * 12 | * By doing this, we can produce an error when launching the service 13 | * if the variable is not set, and avoid that error when testing it. 14 | */ 15 | 16 | process.env.MY_SECRET_VALUE = "MY_SECRET_VALUE"; 17 | process.env.MY_SECRET_NUMBER = 12_345; 18 | -------------------------------------------------------------------------------- /src/features/greeting/greeting.service.ts: -------------------------------------------------------------------------------- 1 | import type { EnvironmentService } from "@core/environment/environment.service"; 2 | import type { ByeService } from "../bye/bye.service"; 3 | 4 | /** 5 | * Dependency Injection (ish) approach 6 | */ 7 | export class GreetingService { 8 | readonly #byeService: ByeService; 9 | readonly #environmentService: EnvironmentService; 10 | 11 | constructor(byeService: ByeService, environmentService: EnvironmentService) { 12 | this.#byeService = byeService; 13 | this.#environmentService = environmentService; 14 | } 15 | 16 | greet(name: string): string { 17 | return this.#byeService.sayBye(name); 18 | } 19 | 20 | useSecretValue(): string { 21 | return this.#environmentService.get("MY_SECRET_VALUE"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require("ts-jest"); 2 | const { compilerOptions } = require("./tsconfig"); 3 | 4 | const COVERAGE_FILE_SUFFIX = ["service", "controller", "handler", "util"]; 5 | 6 | /** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ 7 | module.exports = { 8 | rootDir: ".", 9 | moduleFileExtensions: ["js", "json", "ts"], 10 | testRegex: ".*\\.spec\\.ts$", 11 | transform: { "^.+\\.ts$": "ts-jest" }, 12 | collectCoverageFrom: [`**/*.(${COVERAGE_FILE_SUFFIX.join("|")}).ts`], 13 | setupFilesAfterEnv: ["./test/env.setup.js", "jest-extended/all"], 14 | coverageDirectory: "./coverage", 15 | testEnvironment: "node", 16 | // Helps to use aliases in tsconfig (@module/*) 17 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 18 | prefix: "", 19 | }), 20 | }; 21 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo '🏗️👷 Styling, testing and building your project before committing' 4 | 5 | #!/bin/sh 6 | pnpm lint-staged 7 | 8 | echo '👷🏻 Building project...' 9 | 10 | # Build project 11 | npm run build || ( 12 | echo '🚨 👷🏻 Build try failed 13 | 14 | Build process failed, please check the above errors. 15 | 16 | 💡 You can also run `npm run build` to see the issues and fix them manually. 17 | ' 18 | false 19 | ) 20 | 21 | echo '✅ 👷🏻 Project built successfully. Removing build artifacts...' 22 | 23 | # Remove build artifacts 24 | npm run build:clean || ( 25 | echo '🚨 👷🏻 Build clean failed 26 | 27 | Build clean process failed, please check the above errors. 28 | 29 | 💡 You can also run `npm run build:clean` to see the issues and fix them manually. 30 | ' 31 | false 32 | ) 33 | 34 | echo '✅ All validation checks passed. Commiting... 🤘🏻' 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** biome-ignore-all lint/correctness/noUnusedVariables: This file groups all functions that will be used externally */ 2 | 3 | import { EnvironmentService } from "@core/environment/environment.service"; 4 | import { ByeService } from "@features/bye/bye.service"; 5 | import { GreetingService } from "@features/greeting/greeting.service"; 6 | import { HelloService } from "@features/hello/hello.service"; 7 | 8 | // @ts-expect-error 9 | function main(): void { 10 | const byeService = new ByeService(); 11 | const environmentService = new EnvironmentService(); 12 | const greetingService = new GreetingService(byeService, environmentService); 13 | 14 | const hiMessage = HelloService.sayHi("CLASP"); 15 | const byeMessage = ByeService.sayBye("CLASP"); 16 | const greeting = greetingService.greet("CLASP"); 17 | const secretValue = greetingService.useSecretValue(); 18 | 19 | // biome-ignore lint/suspicious/noConsole: Template-related code 20 | console.log({ hiMessage, byeMessage, greeting, secretValue }); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "lib": ["ESNext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "baseUrl": "./", 13 | "module": "ESNext", 14 | "moduleResolution": "node", 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "allowUnusedLabels": false, 18 | "allowUnreachableCode": false, 19 | "pretty": true, 20 | "alwaysStrict": true, 21 | "noImplicitAny": true, 22 | "noImplicitOverride": true, 23 | "noImplicitThis": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "removeComments": true, 27 | "useUnknownInCatchVariables": true, 28 | "exactOptionalPropertyTypes": true, 29 | "paths": { 30 | "@core/*": ["src/core/*"], 31 | "@features/*": ["src/features/*"] 32 | } 33 | }, 34 | "files": ["node_modules/jest-extended/types/index.d.ts"], 35 | "include": ["src"] 36 | } 37 | -------------------------------------------------------------------------------- /src/core/environment/environment.service.ts: -------------------------------------------------------------------------------- 1 | /** biome-ignore-all lint/style/useNamingConvention: This file represents the environemnt variables names */ 2 | 3 | import type { Environment } from "./environment.type"; 4 | 5 | /** 6 | * Environment variables usage approach 7 | * 8 | * --- 9 | * 10 | * You can use an EnvironmentService in order to 11 | * avoid repeat the `process.env` pattern. 12 | * 13 | * You are free to implement this in any way you 14 | * want, but this is a good starting point. 15 | * 16 | * I recommend you to make extensive usage of 17 | * generics when implementing a single environment 18 | * variable getter method. Like so: 19 | * 20 | * ```typescript 21 | * get(key: Key): Environment[Key] { 22 | * // Implementation... 23 | * } 24 | * ``` 25 | */ 26 | export class EnvironmentService { 27 | get(key: Key): Environment[Key] { 28 | const environment = { 29 | MY_SECRET_VALUE: String(process.env.MY_SECRET_VALUE), 30 | MY_SECRET_NUMBER: Number(process.env.MY_SECRET_NUMBER), 31 | }; 32 | 33 | return environment[key]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const { babel } = require("@rollup/plugin-babel"); 2 | const { nodeResolve } = require("@rollup/plugin-node-resolve"); 3 | const { default: typescript } = require("@rollup/plugin-typescript"); 4 | const { default: dotenv } = require("rollup-plugin-dotenv"); 5 | 6 | const extensions = [".ts", ".js"]; 7 | 8 | const preventThreeShakingPlugin = () => { 9 | return { 10 | name: "no-threeshaking", 11 | resolveId(id, importer) { 12 | // let's not theeshake entry points, as we're not exporting anything in App Scripts 13 | if (!importer) { 14 | return { id, moduleSideEffects: "no-treeshake" }; 15 | } 16 | }, 17 | }; 18 | }; 19 | 20 | module.exports = { 21 | input: "src/index.ts", 22 | output: [ 23 | { 24 | dir: "build", 25 | format: "cjs", 26 | }, 27 | ], 28 | plugins: [ 29 | typescript({ tsconfig: "./tsconfig.build.json" }), 30 | preventThreeShakingPlugin(), 31 | nodeResolve({ 32 | extensions, 33 | mainFields: ["jsnext:main", "main"], 34 | }), 35 | babel({ 36 | extensions, 37 | babelHelpers: "runtime", 38 | comments: false, 39 | }), 40 | dotenv(), 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /src/features/hello/hello.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HelloService } from "./hello.service"; 2 | 3 | describe("HelloService", () => { 4 | describe("sayHi", () => { 5 | describe("sync", () => { 6 | describe("when name is provided", () => { 7 | it("should return hi message with provided name", () => { 8 | const expected = "John"; 9 | const actual = HelloService.sayHi(expected); 10 | expect(actual).toContain(expected); 11 | }); 12 | }); 13 | 14 | describe("when name is not provided", () => { 15 | it("should return hi message with default name", () => { 16 | const actual = HelloService.sayHi(); 17 | expect(actual).toMatchInlineSnapshot(`"Hello, World!"`); 18 | }); 19 | }); 20 | }); 21 | 22 | describe("async", () => { 23 | describe("when name is provided", () => { 24 | it("should return hi async message with provided name", async () => { 25 | const expected = "John"; 26 | const actual = await HelloService.sayHiAsync(expected); 27 | expect(actual).toContain(expected); 28 | }); 29 | }); 30 | 31 | describe("when name is not provided", () => { 32 | it("should return hi async message with default name", async () => { 33 | const actual = await HelloService.sayHiAsync(); 34 | expect(actual).toMatchInlineSnapshot(`"Hello, World!"`); 35 | }); 36 | }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/core/environment/environment.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentService } from "./environment.service"; 2 | import type { Environment } from "./environment.type"; 3 | 4 | describe("EnvironmentService", () => { 5 | const originalEnv = process.env; 6 | 7 | let underTest: EnvironmentService; 8 | 9 | beforeEach(() => { 10 | underTest = new EnvironmentService(); 11 | }); 12 | 13 | afterEach(() => { 14 | process.env = originalEnv; 15 | }); 16 | 17 | describe("get", () => { 18 | describe("when the environment variable is defined", () => { 19 | const environment: Environment = { 20 | // biome-ignore lint/style/useNamingConvention: This is an environment variable 21 | MY_SECRET_VALUE: "secret", 22 | // biome-ignore lint/style/useNamingConvention: This is an environment variable 23 | MY_SECRET_NUMBER: 12_345, 24 | }; 25 | 26 | beforeEach(() => { 27 | process.env = environment as any; 28 | }); 29 | 30 | it.each(Object.keys(environment) as (keyof typeof environment)[])( 31 | "should return the value of the variable called %s", 32 | (key) => { 33 | const expected = environment[key]; 34 | 35 | const actual = underTest.get(key); 36 | 37 | expect(actual).toEqual(expected); 38 | } 39 | ); 40 | }); 41 | 42 | describe("when the environment variable is not defined", () => { 43 | beforeEach(() => { 44 | process.env = {} as any; 45 | }); 46 | 47 | it("should return an undefined", () => { 48 | const actual = underTest.get("NOT_DEFINED_VARIABLE" as any); 49 | 50 | expect(actual).toBeUndefined(); 51 | }); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/features/bye/bye.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ByeService } from "./bye.service"; 2 | 3 | describe("ByeService", () => { 4 | let underTest: ByeService; 5 | 6 | beforeEach(() => { 7 | underTest = new ByeService(); 8 | }); 9 | 10 | describe("sayBye", () => { 11 | describe("non-static", () => { 12 | it("should return bye message with provided name", () => { 13 | const expected = "John"; 14 | const actual = underTest.sayBye(expected); 15 | expect(actual).toContain(expected); 16 | }); 17 | }); 18 | 19 | describe("static", () => { 20 | describe("sync", () => { 21 | describe("when name is provided", () => { 22 | it("should return bye message with provided name", () => { 23 | const expected = "John"; 24 | const actual = ByeService.sayBye(expected); 25 | expect(actual).toContain(expected); 26 | }); 27 | }); 28 | 29 | describe("when name is not provided", () => { 30 | it("should return bye message with default name", () => { 31 | const actual = ByeService.sayBye(); 32 | expect(actual).toMatchInlineSnapshot(`"Bye, World!"`); 33 | }); 34 | }); 35 | }); 36 | 37 | describe("async", () => { 38 | describe("when name is provided", () => { 39 | it("should return bye message with provided name", async () => { 40 | const expected = "John"; 41 | const actual = await ByeService.sayByeAsync(expected); 42 | expect(actual).toContain(expected); 43 | }); 44 | }); 45 | 46 | describe("when name is not provided", () => { 47 | it("should return bye message with default name", async () => { 48 | const actual = await ByeService.sayByeAsync(); 49 | expect(actual).toMatchInlineSnapshot(`"Bye, World!"`); 50 | }); 51 | }); 52 | }); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ez-clasp", 3 | "version": "0.0.5", 4 | "author": "cristobalgvera", 5 | "description": "Google Apps Script starter project with TypeScript support", 6 | "license": "MIT", 7 | "private": true, 8 | "main": "src/index.ts", 9 | "engines": { 10 | "node": ">= 24" 11 | }, 12 | "scripts": { 13 | "build": "rollup --config", 14 | "build:clean": "rimraf build/", 15 | "build:watch": "pnpm build --watch", 16 | "check:all": "pnpm lint --write && pnpm test", 17 | "clasp:create": "rimraf .clasp.json && clasp create --rootDir . --title CHANGE_MY_NAME && pnpm lint --write .clasp.json", 18 | "clasp:login": "clasp login", 19 | "deploy": "npm run check:all && npm run build && npm run push", 20 | "lint": "biome check", 21 | "prepare": "husky", 22 | "push": "clasp push", 23 | "test": "jest", 24 | "test:cov": "pnpm test --coverage", 25 | "test:watch": "pnpm test --watch" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.28.4", 29 | "@babel/plugin-transform-runtime": "^7.28.3", 30 | "@babel/preset-env": "^7.28.3", 31 | "@babel/preset-typescript": "^7.27.1", 32 | "@biomejs/biome": "2.2.4", 33 | "@golevelup/ts-jest": "^0.7.0", 34 | "@google/clasp": "^2.5.0", 35 | "@rollup/plugin-babel": "^6.0.4", 36 | "@rollup/plugin-node-resolve": "^16.0.1", 37 | "@rollup/plugin-typescript": "^12.1.4", 38 | "@types/google-apps-script": "^2.0.4", 39 | "@types/jest": "^30.0.0", 40 | "@types/node": "^24.5.2", 41 | "husky": "^9.1.7", 42 | "jest": "^30.2.0", 43 | "jest-extended": "^6.0.0", 44 | "lint-staged": "^16.2.3", 45 | "rimraf": "^6.0.1", 46 | "rollup": "^4.52.3", 47 | "rollup-plugin-dotenv": "^0.5.1", 48 | "ts-jest": "^29.4.4", 49 | "tslib": "^2.8.1", 50 | "typescript": "^5.9.2", 51 | "ultracite": "5.4.5" 52 | }, 53 | "keywords": [ 54 | "clasp", 55 | "google-apps-script", 56 | "gas" 57 | ], 58 | "lint-staged": { 59 | "*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mdx}": [ 60 | "pnpm dlx ultracite fix" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/features/greeting/greeting.service.spec.ts: -------------------------------------------------------------------------------- 1 | import type { EnvironmentService } from "@core/environment/environment.service"; 2 | import type { ByeService } from "@features/bye/bye.service"; 3 | import { createMock } from "@golevelup/ts-jest"; 4 | import { GreetingService } from "./greeting.service"; 5 | 6 | describe("GreetingService", () => { 7 | let underTest: GreetingService; 8 | let byeService: ByeService; 9 | let environmentService: EnvironmentService; 10 | 11 | beforeEach(() => { 12 | byeService = createMock(); 13 | environmentService = createMock(); 14 | 15 | underTest = new GreetingService(byeService, environmentService); 16 | }); 17 | 18 | it("should be defined", () => { 19 | expect(underTest).toBeDefined(); 20 | expect(byeService).toBeDefined(); 21 | expect(environmentService).toBeDefined(); 22 | }); 23 | 24 | describe("greet", () => { 25 | it("should return greeting message", () => { 26 | const expected = "Greeting message"; 27 | 28 | jest.spyOn(byeService, "sayBye").mockReturnValueOnce(expected); 29 | 30 | const actual = underTest.greet("name"); 31 | 32 | expect(actual).toEqual(expected); 33 | }); 34 | 35 | it("should call ByeService with provided parameter", () => { 36 | const expected = "name"; 37 | 38 | const byeServiceSpy = jest.spyOn(byeService, "sayBye"); 39 | 40 | underTest.greet(expected); 41 | 42 | expect(byeServiceSpy).toHaveBeenCalledWith(expected); 43 | }); 44 | }); 45 | 46 | describe("useSecretValue", () => { 47 | it("should return the value", () => { 48 | const expected = "secret_value"; 49 | 50 | jest.spyOn(environmentService, "get").mockReturnValueOnce(expected); 51 | 52 | const actual = underTest.useSecretValue(); 53 | 54 | expect(actual).toEqual(expected); 55 | }); 56 | 57 | it("should call EnvironmentService", () => { 58 | const environmentServiceSpy = jest.spyOn(environmentService, "get"); 59 | 60 | underTest.useSecretValue(); 61 | 62 | expect(environmentServiceSpy).toHaveBeenCalledTimes(1); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Misc 2 | **/.idea/ 3 | 4 | # TypeScript 5 | build/ 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | **/.nvimlog 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # Snowpack dependency directory (https://snowpack.dev/) 53 | web_modules/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variables file 80 | .env 81 | .env.test 82 | .env.production 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # Serverless directories 106 | .serverless/ 107 | 108 | # FuseBox cache 109 | .fusebox/ 110 | 111 | # DynamoDB Local files 112 | .dynamodb/ 113 | 114 | # TernJS port file 115 | .tern-port 116 | 117 | # Stores VSCode versions used for testing VSCode extensions 118 | .vscode-test 119 | 120 | # yarn v2 121 | .yarn/cache 122 | .yarn/unplugged 123 | .yarn/build-state.yml 124 | .yarn/install-state.gz 125 | .pnp.* 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💻 EZ CLASP 2 | 3 | This project works as a bootstrap to start a new [Google Apps Script](https://developers.google.com/apps-script) 4 | project or attach it to an existing one. 5 | 6 | It will give you, out of the box, a well defined develop flow for your GAS project. 7 | 8 | This workflow will let you to: 9 | 10 | - Use the latest TypeScript features. 11 | - Use the latest EcmaScript features. 12 | - Easily unit test your project, even if your are using the Google-related 13 | interfaces (SpreadsheetApp, DriveApp, DataStudioApp, etc.). 14 | - Maintain your code always formatted and linted in your workspace. 15 | - Manage your code using versioning tools. 16 | - Easily make usage of environment variables. 17 | - Treeshake your code to only deploy the one that is being used. 18 | 19 | Welcome to your new **EZ CLASP** life 🚀 20 | 21 | ## ❓ How to 22 | 23 | ### 🥇 First usage 24 | 25 | 1. Use [this template](https://github.com/cristobalgvera/ez-clasp) by clicking 26 | the **_"Use this template"_** button over repository files. 27 | 28 | You can use `pnpx tiged` way instead: 29 | 30 | ```bash 31 | pnpx tiged cristobalgvera/ez-clasp YOUR_REPOSITORY_NAME 32 | ``` 33 | 34 | 1. Using the new created repository URL, clone it following next steps 35 | 36 | If you used the `template` way: 37 | 38 | ```bash 39 | git clone https://github.com/YOUR_USER_NAME/YOUR_REPOSITORY_NAME.git 40 | cd YOUR_REPOSITORY_NAME 41 | 42 | pnpm install 43 | pnpm clasp:login # And access to your Google account 44 | ``` 45 | 46 | If you used `pnpx tiged` way: 47 | 48 | ```bash 49 | cd YOUR_REPOSITORY_NAME 50 | 51 | git init 52 | pnpm install 53 | pnpm clasp:login # And access to your Google account 54 | ``` 55 | 56 | 1. **If you DO NOT have an existing project**, run `pnpm clasp:create` 57 | to create a new project. CLASP CLI will prompt some project types, you 58 | should select one of them. 59 | 60 | 1. **If you have an existing project**, add your Apps Script ID inside 61 | [`.clasp.json`](./.clasp.json) in the **_scriptId_** key. 62 | 63 | ![Project configuration](assets/images/project-configuration.png) 64 | 65 | 1. Create a `.env` file copying the [.env.example](./.env.example) file. 66 | 67 | ```bash 68 | cp .env.example .env 69 | ``` 70 | 71 | Then your can make proper usage of environemnt variables in case you want. 72 | If you don't, simply remove all environment variable related stuff. 73 | 74 | 1. Push your project to Google Apps Script using 75 | 76 | ```bash 77 | pnpm run deploy 78 | ``` 79 | 80 | The **first time** you execute this command, CLASP CLI will ask you to 81 | overwrite manifest file [`appsscript.json`](./appsscript.json), insert `y` 82 | key and press `enter`. This will let you to fully control your project 83 | from your local environment. 84 | 85 | The `appsscript.json` file contains configuration required by Google to 86 | manage permissions used by your users related to your project. 87 | 88 | 1. **Test the code opening your 89 | [Google Apps Script projects dashboard](https://script.google.com/home/my)!** 90 | 91 | There you can change your project name to the name you want (by default will 92 | be called `CHANGE_MY_NAME`, in order to remember you to change it). 93 | 94 | ### 🤔 How to push HTML or non TypeScript files? 95 | 96 | If you need to push some other files that will not be included in transpilation 97 | process, you can put them into [`app`](./app) folder _(or whatever location you 98 | want if you change [`.claspignore`](./.claspignore) configuration)_. 99 | 100 | You can put your assets in there, e.g. some HTML or any JavaScript file you need 101 | to be pushed to Google Apps Script. 102 | 103 | ⚠️ **Google Apps Script only allows you to push files with these extensions: 104 | `.html`, `.js`**. If you need files like `.css`, see Google Apps Script 105 | [HTML best practices page](https://developers.google.com/apps-script/guides/html/best-practices). 106 | 107 | ### 🤔 How to use environment variables? 108 | 109 | Google provide us a way to handle environment variables using the 110 | [PropertiesService](https://developers.google.com/apps-script/reference/properties/properties-service?hl=es-419). 111 | This way of handling environment variables can easily transform in a mess 112 | if you are working in a developers team. You can check this way in order to 113 | follow Google's approach. 114 | 115 | This project gives you a way to handle environment variables directly 116 | inside your local development using Rollup's plugins. You just need to create 117 | a `.env` file located in the root directory of your project and then 118 | use the secret values simply using the `process.env.YOUR_SECRET_KEY` approach. 119 | You are free to create wrapper services to avoid repeating this pattern and 120 | give plenty type safety to your environment variables. 121 | 122 | In order to test the code that make usage of environment variables, you just need 123 | to add all your required environment variables to the [env.setup.js](./test/env.setup.js) 124 | file. This will make your entire process of testing to use those variables. 125 | 126 | If you want to have detailed control over environment variables, you will 127 | need to control it from your tests directly, e.g. modifying the `process.env` 128 | object to include an specific value. Take care about how to make this 129 | mocking process, remember the isolation of unit tests. Check 130 | [this article](https://razinj.dev/how-to-mock-process-env-in-jest/). 131 | 132 | ### 🗂 How to add Google services, advanced Google services or external libraries? 133 | 134 | When you add a Google service _(Gmail, Google Sheets, etc.)_ which require some 135 | kind of permissions, e.g. permissions to read your email or write in a 136 | spreadsheet, you will need to add those specific permissions 137 | (a.k.a. OAuthScopes) in the file called [`appsscript.json`](./appsscript.json), 138 | in the `oauthScope` array as a string. The list of all OAuthScopes can be 139 | found in [here](https://developers.google.com/identity/protocols/oauth2/scopes). 140 | 141 | Similarly, when you need to use an advanced service, like Drive 142 | _(the old version)_, or a third party library, you will need to add those, 143 | using the required format, in the [`appsscript.json`](./appsscript.json) file, 144 | inside the dependencies object, in one of the arrays. See the required structure 145 | in this [link](http://json.schemastore.org/appsscript) 146 | 147 | #### 🧪 How to test Google libraries 148 | 149 | Google libraries use namespaces as a resource to be imported, meaning there will 150 | no be imported through `import` syntax. This adds a complexity when testing it. 151 | 152 | In order to easily tests those kind of libraries, you will have to mock the global 153 | object, like so: 154 | 155 | ```typescript 156 | // my-class.service.ts 157 | 158 | // In this example `GoogleService` is the service in use provided by Google 159 | export class MyClassService { 160 | someMethodThatUseAnyGoogleService(body: BodyType) { 161 | const childGoogleService = GoogleService.anyMethod(); 162 | 163 | return childGoogleService.childMethod(body); 164 | } 165 | } 166 | ``` 167 | 168 | ```typescript 169 | // my-class.service.spec.ts 170 | 171 | import { createMock } from "@golevelup/ts-jest"; 172 | import { MyClassService } from "./my-class.service.ts"; 173 | 174 | describe("MyClassService", () => { 175 | let underTest: MyClassService; 176 | 177 | beforeEach(() => { 178 | underTest = new MyClassService(); 179 | }); 180 | 181 | describe("someMethodThatUseAnyGoogleService", () => { 182 | const originalService = global.GoogleService; 183 | 184 | let googleService: typeof GoogleService; 185 | let childGoogleService: ReturnType<(typeof googleService)["anyMethod"]>; 186 | 187 | beforeEach(() => { 188 | childGoogleService = createMock(); 189 | googleService = createMock({ 190 | anyMethod: () => childGoogleService, 191 | }); 192 | 193 | global.GoogleService = googleService; 194 | }); 195 | 196 | afterEach(() => { 197 | global.GoogleService = originalService; 198 | }); 199 | 200 | it("should call ChildGoogleService with correct parameters", () => { 201 | const expected = { any: "parameter" }; 202 | 203 | const childGoogleServiceSpy = jest.spyOn( 204 | childGoogleService, 205 | "childMethod", 206 | ); 207 | 208 | underTest.someMethodThatUseAnyGoogleService(expected as any); 209 | 210 | expect(childGoogleServiceSpy).toHaveBeenCalledWith(expected); 211 | }); 212 | 213 | it("should return the value", () => { 214 | const expected = { any: "parameter" }; 215 | 216 | jest 217 | .spyOn(childGoogleService, "childMethod") 218 | .mockReturnValueOnce(expected); 219 | 220 | const actual = underTest.someMethodThatUseAnyGoogleService({} as any); 221 | 222 | expect(actual).toEqual(expected); 223 | }); 224 | }); 225 | }); 226 | ``` 227 | 228 | ## 🍕 Extras 229 | 230 | ### 📚 Libs 231 | 232 | This project contain these libraries that will help you to have a better 233 | development experience: 234 | 235 | - **TypeScript** _(Development)_. 236 | - **Biome** + **Ultracite** _(Format and linting)_. 237 | - **Babel** + **Rollup** _(Building)_. 238 | - **Husky** + **Lint-Staged** _(Commit)_. 239 | - **Jest** _(Testing)_. 240 | 241 | #### 🐾 Husky + Lint-Staged 242 | 243 | In order to improve commitment flow when working with teams, template has a pre 244 | configured Husky implementation. The pre-commit flow is: 245 | 246 | - Fix **linter** issues, when possible. 247 | - Fix **format** issues, when possible. 248 | - **Build** the code. 249 | 250 | All tasks but build run only for the staged changes. 251 | 252 | ### ❗ Ignored files 253 | 254 | In case you want ignore certain files to be pushed, you can add them to the 255 | [`.claspignore`](./.claspignore) file. You can see in it some already ignored 256 | base directories. 257 | 258 | _P.D.: Of course you can delete this **README** file and the [`assets`](./assets) 259 | folder._ 260 | 261 | ## 💼 Example projects 262 | 263 | You can found here some practical example usages of this template in order to 264 | help you to understand better how to link it to a Google Apps Script project: 265 | 266 | - **[Automatic FUP](https://github.com/cristobalgvera/automatic-fup)** 267 | _(include connection to Firebase)_ 268 | - **[Open Orders Update](https://github.com/cristobalgvera/open-orders-update)** 269 | - **[CMIC Credentials](https://github.com/cristobalgvera/cmic-credentials)** 270 | - **[Expeditures Projection](https://github.com/cristobalgvera/expeditures-projection)** 271 | --------------------------------------------------------------------------------