diff --git a/src/auth.ts b/src/auth.ts index 00961ce7..77fdb7e5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,4 @@ -import {Credentials, GenerateAuthUrlOpts, OAuth2Client, OAuth2ClientOptions} from 'google-auth-library'; +import {OAuth2Client} from 'google-auth-library'; import {google, script_v1 as scriptV1} from 'googleapis'; import {createServer} from 'http'; import open from 'open'; @@ -6,15 +6,16 @@ import readline from 'readline'; import {URL} from 'url'; import {ClaspError} from './clasp-error'; -import {ClaspToken, DOTFILE, Dotfile} from './dotfile'; +import {DOTFILE} from './dotfile'; import {ERROR, LOG} from './messages'; import {checkIfOnlineOrDie, getOAuthSettings} from './utils'; -// import {oauthScopesPrompt} from './inquirer'; -// import {readManifest} from './manifest'; +import type {Credentials, GenerateAuthUrlOpts, OAuth2ClientOptions} from 'google-auth-library'; import type {IncomingMessage, Server, ServerResponse} from 'http'; import type {AddressInfo} from 'net'; import type {ReadonlyDeep} from 'type-fest'; + +import type {ClaspToken} from './dotfile'; import type {ClaspCredentials} from './utils'; /** @@ -151,9 +152,8 @@ export const authorize = async (options: { // Save the token and own creds together. let claspToken: ClaspToken; - let dotfile: Dotfile; + // TODO: deprecate `--creds` option if (options.creds) { - dotfile = DOTFILE.RC_LOCAL(); const {client_id: clientId, client_secret: clientSecret, redirect_uris: redirectUri} = options.creds.installed; // Save local ClaspCredentials. claspToken = { @@ -162,7 +162,6 @@ export const authorize = async (options: { isLocalCreds: true, }; } else { - dotfile = DOTFILE.RC; // Save global ClaspCredentials. claspToken = { token, @@ -171,7 +170,7 @@ export const authorize = async (options: { }; } - await dotfile.write(claspToken); + await DOTFILE.AUTH().write(claspToken); console.log(LOG.SAVED_CREDS(Boolean(options.creds))); } catch (error) { if (error instanceof ClaspError) { @@ -310,7 +309,7 @@ const setOauthClientCredentials = async (rc: ClaspToken) => { await refreshCredentials(globalOAuth2Client); // Save the credentials. - await (rc.isLocalCreds ? DOTFILE.RC_LOCAL() : DOTFILE.RC).write(rc); + await DOTFILE.AUTH().write(rc); } catch (error) { if (error instanceof ClaspError) { throw error; diff --git a/src/commands/logout.ts b/src/commands/logout.ts index b81f8616..560f6928 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,17 +1,40 @@ -import fs from 'fs-extra'; - -import {DOT} from '../dotfile'; +import {Conf} from '../conf'; +import {DOTFILE} from '../dotfile'; import {hasOauthClientSettings} from '../utils'; +const {auth} = Conf.get(); + /** * Logs out the user by deleting credentials. */ export default async (): Promise => { + let previousPath: string | undefined; + if (hasOauthClientSettings(true)) { - fs.unlinkSync(DOT.RC.ABSOLUTE_LOCAL_PATH); + if (auth.isDefault()) { + // if no local auth defined, try current directory + previousPath = auth.path; + auth.path = '.'; + } + + await DOTFILE.AUTH().delete(); + + if (previousPath) { + auth.path = previousPath; + } } if (hasOauthClientSettings()) { - fs.unlinkSync(DOT.RC.ABSOLUTE_PATH); + if (!auth.isDefault()) { + // if local auth defined, try with default (global) + previousPath = auth.path; + auth.path = ''; + } + + await DOTFILE.AUTH().delete(); + + if (previousPath) { + auth.path = previousPath; + } } }; diff --git a/src/commands/push.ts b/src/commands/push.ts index fec54a9d..f29f9d76 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -6,14 +6,19 @@ import {watchTree} from 'watch'; import {loadAPICredentials} from '../auth'; import {ClaspError} from '../clasp-error'; +import {Conf} from '../conf'; import {FS_OPTIONS, PROJECT_MANIFEST_BASENAME, PROJECT_MANIFEST_FILENAME} from '../constants'; -import {DOT, DOTFILE, ProjectSettings} from '../dotfile'; +import {DOTFILE} from '../dotfile'; import {fetchProject, pushFiles} from '../files'; import {overwritePrompt} from '../inquirer'; import {isValidManifest} from '../manifest'; import {LOG} from '../messages'; import {checkIfOnlineOrDie, getProjectSettings, spinner} from '../utils'; +import type {ProjectSettings} from '../dotfile'; + +const {project} = Conf.get(); + interface CommandOption { readonly watch?: boolean; readonly force?: boolean; @@ -81,7 +86,7 @@ const confirmManifestUpdate = async (): Promise => (await overwriteProm * @returns {Promise} */ const manifestHasChanges = async (projectSettings: ProjectSettings): Promise => { - const {scriptId, rootDir = DOT.PROJECT.DIR} = projectSettings; + const {scriptId, rootDir = project.resolvedDir} = projectSettings; const localManifest = readFileSync(path.join(rootDir, PROJECT_MANIFEST_FILENAME), FS_OPTIONS); const remoteFiles = await fetchProject(scriptId, undefined, true); const remoteManifest = remoteFiles.find(file => file.name === PROJECT_MANIFEST_BASENAME); diff --git a/src/conf.ts b/src/conf.ts new file mode 100644 index 00000000..a2ffb228 --- /dev/null +++ b/src/conf.ts @@ -0,0 +1,117 @@ +import os from 'os'; +import path from 'path'; + +import {PROJECT_NAME} from './constants'; +import {PathProxy} from './path-proxy'; + +/** + * supported environment variables + */ +enum ENV { + DOT_CLASP_AUTH = 'clasp_config_auth', + DOT_CLASP_IGNORE = 'clasp_config_ignore', + DOT_CLASP_PROJECT = 'clasp_config_project', + // MANIFEST = 'clasp_config_manifest', + // TSCONFIG = 'clasp_config_tsconfig', +} + +/** + * A Singleton class to hold configuration related objects. + * Use the `get()` method to access the unique singleton instance. + */ +export class Conf { + private static _instance: Conf; + /** + * This dotfile saves clasp project information, local to project directory. + */ + readonly project: PathProxy; + /** + * This dotfile stores information about ignoring files on `push`. Like .gitignore. + */ + readonly ignore: IgnoreFile; + /** + * This dotfile saves auth information. Should never be committed. + * There are 2 types: personal & global: + * - Global: In the $HOME directory. + * - Personal: In the local directory. + * @see {ClaspToken} + */ + readonly auth: AuthFile; + // readonly manifest: PathProxy; + + /** + * Private to prevent direct construction calls with the `new` operator. + */ + private constructor() { + /** + * Helper to set the PathProxy path if an environment variables is set. + * + * *Note: Empty values (i.e. '') are not accounted for.* + */ + const setPathWithEnvVar = (varName: string, file: PathProxy) => { + const envVar = process.env[varName]; + if (envVar) { + file.path = envVar; + } + }; + + // default `project` path is `./.clasp.json` + this.project = new PathProxy({dir: '.', base: `.${PROJECT_NAME}.json`}); + + // default `ignore` path is `~/.claspignore` + // IgnoreFile class implements custom `.resolve()` rules + this.ignore = new IgnoreFile({dir: os.homedir(), base: `.${PROJECT_NAME}ignore`}); + + // default `auth` path is `~/.clasprc.json` + // Default Auth is global. Any other implies local auth + this.auth = new AuthFile({dir: os.homedir(), base: `.${PROJECT_NAME}rc.json`}); + + // resolve environment variables + setPathWithEnvVar(ENV.DOT_CLASP_PROJECT, this.project); + setPathWithEnvVar(ENV.DOT_CLASP_IGNORE, this.ignore); + setPathWithEnvVar(ENV.DOT_CLASP_AUTH, this.auth); + } + + /** + * The static method that controls the access to the Conf singleton instance. + * + * @returns {Conf} + */ + static get(): Conf { + if (!Conf._instance) { + Conf._instance = new Conf(); + } + + return Conf._instance; + } +} + +class AuthFile extends PathProxy { + /** + * Rules to resolves path: + * + * - if default path, use as is + * - otherwise use super.resolve() + * + * @returns {string} + */ + resolve(): string { + return this.isDefault() ? path.join(this._default.dir, this._default.base) : super.resolve(); + } +} + +class IgnoreFile extends PathProxy { + /** + * Rules to resolves path: + * + * - if default, use the **project** directory and the default base filename + * - otherwise use super.resolve() + * + * @returns {string} + */ + resolve(): string { + return this.isDefault() ? path.join(Conf.get().project.resolvedDir, this._default.base) : super.resolve(); + } +} + +// TODO: add more subclasses if necessary diff --git a/src/dotfile.ts b/src/dotfile.ts index 551b070b..e7cb7710 100644 --- a/src/dotfile.ts +++ b/src/dotfile.ts @@ -11,17 +11,18 @@ * * This should be the only file that uses DOTFILE. */ - -import os from 'os'; -import path from 'path'; -import findUp from 'find-up'; -import fs from 'fs-extra'; -import {Credentials, OAuth2ClientOptions} from 'google-auth-library'; -import stripBom from 'strip-bom'; import dotf from 'dotf'; +import fs from 'fs-extra'; +import path from 'path'; import splitLines from 'split-lines'; +import stripBom from 'strip-bom'; + +import {Conf} from './conf'; +import {FS_OPTIONS} from './constants'; + +import type {Credentials, OAuth2ClientOptions} from 'google-auth-library'; -import {FS_OPTIONS, PROJECT_NAME} from './constants'; +const {auth, ignore, project} = Conf.get(); export {Dotfile} from 'dotf'; @@ -35,42 +36,6 @@ export interface ProjectSettings { parentId?: string[]; } -// Dotfile names -export const DOT = { - /** - * This dotfile stores information about ignoring files on `push`. Like .gitignore. - */ - IGNORE: { - DIR: '~', - NAME: `${PROJECT_NAME}ignore`, - PATH: `.${PROJECT_NAME}ignore`, - }, - /** - * This dotfile saves clasp project information, local to project directory. - */ - PROJECT: { - DIR: path.join('.', '/'), // Relative to where the command is run. See DOTFILE.PROJECT() - NAME: `${PROJECT_NAME}.json`, - PATH: `.${PROJECT_NAME}.json`, - }, - /** - * This dotfile saves auth information. Should never be committed. - * There are 2 types: personal & global: - * - Global: In the $HOME directory. - * - Personal: In the local directory. - * @see {ClaspToken} - */ - RC: { - DIR: '~', - LOCAL_DIR: './', - NAME: `${PROJECT_NAME}rc.json`, - LOCAL_PATH: `.${PROJECT_NAME}rc.json`, - PATH: path.join('~', `.${PROJECT_NAME}rc.json`), - ABSOLUTE_PATH: path.join(os.homedir(), `.${PROJECT_NAME}rc.json`), - ABSOLUTE_LOCAL_PATH: path.join('.', `.${PROJECT_NAME}rc.json`), - }, -}; - const defaultClaspignore = `# ignore all files… **/** @@ -89,16 +54,12 @@ node_modules/** // Methods for retrieving dotfiles. export const DOTFILE = { /** - * Reads DOT.IGNORE.PATH to get a glob pattern of ignored paths. + * Reads ignore.resolve() to get a glob pattern of ignored paths. * @return {Promise} A list of file glob patterns */ IGNORE: async () => { - const localPath = await findUp(DOT.PROJECT.PATH); - const usePath = path.join(localPath ? path.dirname(localPath) : DOT.PROJECT.DIR); - const content = - fs.existsSync(usePath) && fs.existsSync(DOT.IGNORE.PATH) - ? fs.readFileSync(DOT.IGNORE.PATH, FS_OPTIONS) - : defaultClaspignore; + const ignorePath = ignore.resolve(); + const content = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, FS_OPTIONS) : defaultClaspignore; return splitLines(stripBom(content)).filter((name: string) => name.length > 0); }, @@ -108,17 +69,21 @@ export const DOTFILE = { * @return {Dotf} A dotf with that dotfile. Null if there is no file */ PROJECT: () => { - const localPath = findUp.sync(DOT.PROJECT.PATH); - const usePath = localPath ? path.dirname(localPath) : DOT.PROJECT.DIR; - return dotf(usePath, DOT.PROJECT.NAME); + // ! TODO: currently limited if filename doesn't start with a dot '.' + const {dir, base} = path.parse(project.resolve()); + if (base[0] === '.') { + return dotf(dir || '.', base.slice(1)); + } + throw new Error('Project file must start with a dot (i.e. .clasp.json)'); }, // Stores {ClaspCredentials} - RC: dotf(DOT.RC.DIR, DOT.RC.NAME), - // Stores {ClaspCredentials} - RC_LOCAL: () => { - const localPath = findUp.sync(DOT.PROJECT.PATH); - const usePath = localPath ? path.dirname(localPath) : DOT.RC.LOCAL_DIR; - return dotf(usePath, DOT.RC.NAME); + AUTH: () => { + // ! TODO: currently limited if filename doesn't start with a dot '.' + const {dir, base} = path.parse(auth.resolve()); + if (base[0] === '.') { + return dotf(dir || '.', base.slice(1)); + } + throw new Error('Auth file must start with a dot (i.e. .clasp.json)'); }, }; diff --git a/src/files.ts b/src/files.ts index 5015c1ca..c453f7c1 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,4 +1,3 @@ -import findUp from 'find-up'; import fs from 'fs-extra'; import makeDir from 'make-dir'; import multimatch from 'multimatch'; @@ -9,11 +8,14 @@ import ts from 'typescript'; import {loadAPICredentials, script} from './auth'; import {ClaspError} from './clasp-error'; +import {Conf} from './conf'; import {FS_OPTIONS, PROJECT_MANIFEST_FILENAME} from './constants'; -import {DOT, DOTFILE} from './dotfile'; +import {DOTFILE} from './dotfile'; import {ERROR, LOG} from './messages'; import {checkIfOnlineOrDie, getApiFileType, getErrorMessage, getProjectSettings, spinner, stopSpinner} from './utils'; +const {project} = Conf.get(); + // An Apps Script API File interface AppsScriptFile { readonly name: string; @@ -35,7 +37,11 @@ const projectFileWithContent = (file: ProjectFile, transpileOptions: ts.Transpil return type === 'TS' ? // Transpile TypeScript to Google Apps Script // @see github.com/grant/ts2gas - {...file, source: ts2gas(source, transpileOptions as any), type: 'SERVER_JS'} + { + ...file, + source: ts2gas(source, transpileOptions as any), + type: 'SERVER_JS', + } : {...file, source, type}; }; @@ -191,15 +197,14 @@ export const getLocalFileType = (type: string, fileExtension?: string): string = * Returns true if the user has a clasp project. * @returns {boolean} If .clasp.json exists. */ -export const hasProject = (): boolean => fs.existsSync(DOT.PROJECT.PATH); +export const hasProject = (): boolean => fs.existsSync(project.resolve()); /** * Returns in tsconfig.json. * @returns {ts.TranspileOptions} if tsconfig.json not exists, return an empty object. */ const getTranspileOptions = (): ts.TranspileOptions => { - const projectPath = findUp.sync(DOT.PROJECT.PATH); - const tsconfigPath = path.join(projectPath ? path.dirname(projectPath) : DOT.PROJECT.DIR, 'tsconfig.json'); + const tsconfigPath = path.join(project.resolvedDir, 'tsconfig.json'); return fs.existsSync(tsconfigPath) ? { diff --git a/src/index.ts b/src/index.ts index ff5ed466..d2cb2601 100755 --- a/src/index.ts +++ b/src/index.ts @@ -46,9 +46,13 @@ import status from './commands/status'; import undeploy from './commands/undeploy'; import version from './commands/version'; import versions from './commands/versions'; +import {Conf} from './conf'; import {PROJECT_NAME} from './constants'; import {spinner, stopSpinner} from './utils'; +// instantiate the config singleton (and loads environment variables as a side effect) +const {auth, ignore, project} = Conf.get(); + // Ensure any unhandled exception won't go unnoticed loudRejection(); @@ -67,6 +71,33 @@ commander.version(manifest ? manifest.packageJson.version : 'unknown', '-v, --ve commander.name(PROJECT_NAME).usage(' [options]').description(`${PROJECT_NAME} - The Apps Script CLI`); +/** + * Path to an auth file, or to a folder with a '.clasprc.json' file. + */ +commander + .option('-A, --auth ', `path to an auth file or a folder with a '.clasprc.json' file.`) + .on('option:auth', () => { + auth.path = commander['auth']; + }); + +/** + * Path to an ignore file, or to a folder with a '.claspignore'. + */ +commander + .option('-I, --ignore ', `path to an ignore file or a folder with a '.claspignore' file.`) + .on('option:ignore', () => { + ignore.path = commander['ignore']; + }); + +/** + * Path to a project file, or to a folder with a '.clasp.json'. + */ +commander + .option('-P, --project ', `path to a project file or to a folder with a '.clasp.json' file.`) + .on('option:project', () => { + project.path = commander['project']; + }); + /** * Logs the user in. Saves the client credentials to an rc file. * @name login @@ -343,6 +374,19 @@ commander */ commander.command('*', {isDefault: true}).description('Any other command is not supported').action(defaultCmd); +/** + * @internal + * Displays clasp paths + */ +commander + .command('paths') + .description('List current config files path') + .action(() => { + console.log('project', project.path, project.isDefault(), project.resolve()); + console.log('ignore', ignore.path, ignore.isDefault(), ignore.resolve()); + console.log('auth', auth.path, auth.isDefault(), auth.resolve()); + }); + // @ts-expect-error 'xxx' is declared but its value is never read. const [_bin, _sourcePath, ...args] = process.argv; // Defaults to help if commands are not provided diff --git a/src/manifest.ts b/src/manifest.ts index 758a4534..fb8cdf3a 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,19 +1,24 @@ import fs from 'fs-extra'; import path from 'path'; -import {AdvancedService, PUBLIC_ADVANCED_SERVICES as publicAdvancedServices} from './apis'; +import {PUBLIC_ADVANCED_SERVICES as publicAdvancedServices} from './apis'; import {ClaspError} from './clasp-error'; +import {Conf} from './conf'; import {FS_OPTIONS, PROJECT_MANIFEST_FILENAME} from './constants'; -import {DOT, ProjectSettings} from './dotfile'; +import {ProjectSettings} from './dotfile'; import {ERROR} from './messages'; import {getProjectSettings, parseJsonOrDie} from './utils'; +import type {AdvancedService} from './apis'; + +const {project} = Conf.get(); + /*** Gets the path to manifest for given `rootDir` */ const getManifestPath = (rootDir: string): string => path.join(rootDir, PROJECT_MANIFEST_FILENAME); /** Gets the `rootDir` from given project */ -const getRootDir = (project: ProjectSettings): string => - typeof project.rootDir === 'string' ? project.rootDir : DOT.PROJECT.DIR; +const getRootDir = (projectSettings: ProjectSettings): string => + typeof projectSettings.rootDir === 'string' ? projectSettings.rootDir : project.resolvedDir; /** * Checks if the rootDir appears to be a valid project. @@ -22,7 +27,8 @@ const getRootDir = (project: ProjectSettings): string => * * @return {boolean} True if valid project, false otherwise */ -export const manifestExists = (rootDir: string = DOT.PROJECT.DIR): boolean => fs.existsSync(getManifestPath(rootDir)); +export const manifestExists = (rootDir: string = project.resolvedDir): boolean => + fs.existsSync(getManifestPath(rootDir)); /** * Reads the appsscript.json manifest file. diff --git a/src/messages.ts b/src/messages.ts index 9f2bac49..7576f769 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,10 +1,11 @@ -import chalk from 'chalk'; import {script_v1 as scriptV1} from 'googleapis'; +import {Conf} from './conf'; import {PROJECT_MANIFEST_FILENAME, PROJECT_NAME} from './constants'; -import {DOT} from './dotfile'; import {URL} from './urls'; +const {auth, ignore, project} = Conf.get(); + /** Human friendly Google Drive file type name */ const fileTypeName = new Map([ ['docs', 'Google Doc'], @@ -43,7 +44,7 @@ Forgot ${PROJECT_NAME} commands? Get help:\n ${PROJECT_NAME} --help`, DEPLOYMENT_COUNT: 'Unable to deploy; Scripts may only have up to 20 versioned deployments at a time.', DRIVE: 'Something went wrong with the Google Drive API', EXECUTE_ENTITY_NOT_FOUND: 'Script API executable not published/deployed.', - FOLDER_EXISTS: `Project file (${DOT.PROJECT.PATH}) already exists.`, + FOLDER_EXISTS: `Project file (${project.resolve()}) already exists.`, FS_DIR_WRITE: 'Could not create directory.', FS_FILE_WRITE: 'Could not write file.', INVALID_JSON: 'Input params not Valid JSON string. Please fix and try again', @@ -57,8 +58,8 @@ Forgot ${PROJECT_NAME} commands? Get help:\n ${PROJECT_NAME} --help`, NO_CREDENTIALS: (local: boolean) => `Could not read API credentials. Are you logged in ${local ? 'locally' : 'globally'}?`, NO_FUNCTION_NAME: 'N/A', - NO_GCLOUD_PROJECT: `No projectId found in your ${DOT.PROJECT.PATH} file.`, - NO_PARENT_ID: `No parentId or empty parentId found in your ${DOT.PROJECT.PATH} file.`, + NO_GCLOUD_PROJECT: `No projectId found in your ${project.resolve()} file.`, + NO_PARENT_ID: `No parentId or empty parentId found in your ${project.resolve()} file.`, NO_LOCAL_CREDENTIALS: `Requires local crendetials:\n\n ${PROJECT_NAME} login --creds `, NO_MANIFEST: (filename: string) => `Manifest: ${filename} invalid. \`create\` or \`clone\` a project first.`, NO_NESTED_PROJECTS: '\nNested clasp projects are not supported.', @@ -75,14 +76,14 @@ Forgot ${PROJECT_NAME} commands? Get help:\n ${PROJECT_NAME} --help`, RATE_LIMIT: 'Rate limit exceeded. Check quota.', RUN_NODATA: 'Script execution API returned no data.', READ_ONLY_DELETE: 'Unable to delete read-only deployment.', - SCRIPT_ID_DNE: `No scriptId found in your ${DOT.PROJECT.PATH} file.`, + SCRIPT_ID_DNE: `No scriptId found in your ${project.resolve()} file.`, SCRIPT_ID_INCORRECT: (scriptId: string) => `The scriptId "${scriptId}" looks incorrect. Did you provide the correct scriptId?`, SCRIPT_ID: `Could not find script. Did you provide the correct scriptId? Are you logged in to the correct account with the script?`, SETTINGS_DNE: ` -No valid ${DOT.PROJECT.PATH} project file. You may need to \`create\` or \`clone\` a project first.`, +No valid ${project.resolve()} project file. You may need to \`create\` or \`clone\` a project first.`, UNAUTHENTICATED_LOCAL: 'Error: Local client credentials unauthenticated. Check scopes/authorization.', UNAUTHENTICATED: 'Error: Unauthenticated request: Please try again.', UNKNOWN_KEY: (key: string) => `Unknown key "${key}"`, @@ -101,9 +102,9 @@ export const LOG = { AUTH_PAGE_SUCCESSFUL: 'Logged in! You may close this page. ', // HTML Redirect Page AUTH_SUCCESSFUL: 'Authorization successful.', AUTHORIZE: (authUrl: string) => `🔑 Authorize ${PROJECT_NAME} by visiting this url:\n${authUrl}\n`, - CLONE_SUCCESS: (fileCount: number) => `Warning: files in subfolder are not accounted for unless you set a '${ - DOT.IGNORE.PATH - }' file. + CLONE_SUCCESS: ( + fileCount: number + ) => `Warning: files in subfolder are not accounted for unless you set a '${ignore.resolve()}' file. Cloned ${fileCount} ${fileCount === 1 ? 'file' : 'files'}.`, CLONING: 'Cloning files…', CLONE_SCRIPT_QUESTION: 'Clone which script?', @@ -128,7 +129,7 @@ Cloned ${fileCount} ${fileCount === 1 ? 'file' : 'files'}.`, GET_PROJECT_ID_INSTRUCTIONS: `Go to *Resource > Cloud Platform Project…* and copy your projectId (including "project-id-")`, GIVE_DESCRIPTION: 'Give a description: ', - LOCAL_CREDS: `Using local credentials: ${DOT.RC.LOCAL_DIR}${DOT.RC.NAME} 🔐 `, + LOCAL_CREDS: `Using local credentials: ${auth.resolve()} 🔐 `, LOGIN: (isLocal: boolean) => `Logging in ${isLocal ? 'locally' : 'globally'}…`, LOGS_SETUP: 'Finished setting up logs.\n', NO_GCLOUD_PROJECT: `No projectId found. Running ${PROJECT_NAME} logs --setup.`, @@ -147,9 +148,9 @@ Cloned ${fileCount} ${fileCount === 1 ? 'file' : 'files'}.`, PUSHING: 'Pushing files…', SAVED_CREDS: (isLocalCreds: boolean) => isLocalCreds - ? `Local credentials saved to: ${DOT.RC.LOCAL_DIR}${DOT.RC.ABSOLUTE_LOCAL_PATH}. + ? `Local credentials saved to: ${auth.resolve()}. *Be sure to never commit this file!* It's basically a password.` - : `Default credentials saved to: ${DOT.RC.PATH} (${DOT.RC.ABSOLUTE_PATH}).`, + : `Default credentials saved to: ${auth.resolve()}.`, SCRIPT_LINK: (scriptId: string) => `https://script.google.com/d/${scriptId}/edit`, // SCRIPT_RUN: (functionName: string) => `Executing: ${functionName}`, STACKDRIVER_SETUP: 'Setting up StackDriver Logging.', @@ -163,15 +164,14 @@ Cloned ${fileCount} ${fileCount === 1 ? 'file' : 'files'}.`, VERSION_DESCRIPTION: ({versionNumber, description}: scriptV1.Schema$Version) => `${versionNumber} - ${description ?? '(no description)'}`, VERSION_NUM: (versionsCount: number) => `~ ${versionsCount} ${versionsCount === 1 ? 'Version' : 'Versions'} ~`, - // TODO: `SETUP_LOCAL_OAUTH` is never used - SETUP_LOCAL_OAUTH: (projectId: string) => `1. Create a client ID and secret: - Open this link: ${chalk.blue(URL.CREDS(projectId))} - Click ${chalk.cyan('Create credentials')}, then select ${chalk.yellow('OAuth client ID')}. - Select ${chalk.yellow('Other')}. - Give the client a ${chalk.yellow('name')}. - Click ${chalk.cyan('Create')}. - Click ${chalk.cyan('Download JSON')} for the new client ID: ${chalk.yellow('name')} (right-hand side). + // SETUP_LOCAL_OAUTH: (projectId: string) => `1. Create a client ID and secret: + // Open this link: ${chalk.blue(URL.CREDS(projectId))} + // Click ${chalk.cyan('Create credentials')}, then select ${chalk.yellow('OAuth client ID')}. + // Select ${chalk.yellow('Other')}. + // Give the client a ${chalk.yellow('name')}. + // Click ${chalk.cyan('Create')}. + // Click ${chalk.cyan('Download JSON')} for the new client ID: ${chalk.yellow('name')} (right-hand side). -2. Authenticate clasp with your credentials json file: - clasp login --creds `, + // 2. Authenticate clasp with your credentials json file: + // clasp login --creds `, }; diff --git a/src/path-proxy.ts b/src/path-proxy.ts new file mode 100644 index 00000000..2472ba48 --- /dev/null +++ b/src/path-proxy.ts @@ -0,0 +1,131 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import {ClaspError} from './clasp-error'; + +/** A path broken down into a `dir`ectory and a `base` filename */ +type BrokenPath = Pick; + +export class PathProxy { + protected _default: BrokenPath; + protected _userDefined: string | undefined; + + /** + * Handles a path to a file. + * + * - Constructor requires a default path (directory and filename) + * - Path can be overridden with the `path` accessor. + * - The `resolve()` method implements specific rules to define the effective path to the proxied file. + * + * @param {BrokenPath} defaultPath default path + */ + constructor(defaultPath: BrokenPath) { + this._default = defaultPath; + } + + /** + * Returns the current (raw and unresolved) defined path to the proxied file. + * + * *Note: for most uses, prefer the `resolve()` method in order to retreive a file's path* + * + * @returns {string} + */ + get path(): string { + return this._userDefined || path.join(this._default.dir, this._default.base); + } + + /** + * Sets the current (raw and unresolved) path to the proxied file. + * + * *Note: passing an empty string restores the default path* + */ + set path(userDefined: string) { + this._userDefined = userDefined === path.join(this._default.dir, this._default.base) ? undefined : userDefined; + } + + /** + * Returns true if current path is the default. + * + * @returns {boolean} + */ + isDefault(): boolean { + return !this._userDefined || this._userDefined === path.join(this._default.dir, this._default.base); + } + + /** + * Returns the resolved directory to the proxied file. + * + * *Note: for most uses, prefer the `.resolve()` method in order to retreive a file's path* + * + * @returns {string} + */ + get resolvedDir(): string { + return path.dirname(this.resolve()); + } + + /** + * Resolves the current active path + * + * @returns {string} + */ + resolve(): string { + return this._userDefined + ? resolvePath(this._userDefined, this._default.base) + : path.join(this._default.dir, this._default.base); + } +} + +/** + * Attempts to resolve a path with the following rules: + * + * - if path exists and points to a file: use it as is + * - if path exists and points to a directory: append the default base filename to the path + * - if path partially resolves to an existing directory but base filename does not exists: use it as is + * - otherwise throw an error + * + * @param {string} pathToResolve the path to resolve + * @param {string} baseFilename the default base filename + * + * @returns {string} + */ +const resolvePath = (pathToResolve: string, baseFilename: string) => { + if (fs.existsSync(pathToResolve)) { + return appendBaseIfIsDirectory(pathToResolve, baseFilename); + } + + const parsedPath = path.parse(pathToResolve); + + if (parsedPath.dir === '' || fs.lstatSync(parsedPath.dir).isDirectory()) { + return pathToResolve; // assume fullpath to missing file + } + + // TODO: improve support for unresolved paths + throw new ClaspError(`Unrecognized path ${pathToResolve}`); +}; + +/** + * Attempts to resolve an **existing** path using the following rules: + * + * - if path exists and points to a file: use it as is + * - if path exists and points to a directory: append the default base filename to the path + * - otherwise throw an error + * + * @param {string} somePath the path to resolve + * @param {string} baseFilename the default base filename + * + * @returns {string} + */ +const appendBaseIfIsDirectory = (somePath: string, baseFilename: string): string => { + const stats = fs.lstatSync(somePath); + + if (stats.isFile()) { + return somePath; + } + + if (stats.isDirectory()) { + return path.join(somePath, baseFilename); + } + + // TODO: improve support for other stats types (stats.isSymbolicLink() ? ) + throw new ClaspError(`Unrecognized path ${somePath}`); +}; diff --git a/src/utils.ts b/src/utils.ts index ab4ccceb..906719ee 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,10 +6,15 @@ import ora from 'ora'; import path from 'path'; import {ClaspError} from './clasp-error'; -import {ClaspToken, DOT, DOTFILE, ProjectSettings} from './dotfile'; +import {Conf} from './conf'; +import {DOTFILE} from './dotfile'; import {projectIdPrompt} from './inquirer'; import {ERROR, LOG} from './messages'; +import type {ClaspToken, ProjectSettings} from './dotfile'; + +const {auth} = Conf.get(); + /** * Returns input string with uppercased first character */ @@ -40,8 +45,23 @@ export interface ClaspCredentials { * @param {boolean} local check ./clasprc.json instead of ~/.clasprc.json * @return {boolean} */ -export const hasOauthClientSettings = (local = false): boolean => - fs.existsSync(local ? DOT.RC.ABSOLUTE_LOCAL_PATH : DOT.RC.ABSOLUTE_PATH); +export const hasOauthClientSettings = (local = false): boolean => { + let previousPath: string | undefined; + + if (local && auth.isDefault()) { + // if no local auth defined, try current directory + previousPath = auth.path; + auth.path = '.'; + } + + const result = (local ? !auth.isDefault() : auth.isDefault()) && fs.existsSync(auth.resolve()); + + if (previousPath) { + auth.path = previousPath; + } + + return result; +}; /** * Gets the OAuth client settings from rc file. @@ -51,7 +71,21 @@ export const hasOauthClientSettings = (local = false): boolean => */ export const getOAuthSettings = async (local: boolean): Promise => { try { - return await (local ? DOTFILE.RC_LOCAL() : DOTFILE.RC).read(); + let previousPath: string | undefined; + + if (local && auth.isDefault()) { + // if no local auth defined, try current directory + previousPath = auth.path; + auth.path = '.'; + } + + const result = DOTFILE.AUTH().read(); + + if (previousPath) { + auth.path = previousPath; + } + + return result; } catch (error) { throw new ClaspError(getErrorMessage(error) ?? ERROR.NO_CREDENTIALS(local)); }