@google/clasp 3.0.1-alpha1 → 3.0.3-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,7 +81,7 @@ clasp
81
81
  - [`clasp clone-script <scriptId | scriptURL> [versionNumber] [--rootDir <dir>]`](#clone)
82
82
  - [`clasp pull [--versionNumber]`](#pull)
83
83
  - [`clasp push [--watch] [--force]`](#push)
84
- - [`clasp status [--json]`](#status)
84
+ - [`clasp show-file-status [--json]`](#status)
85
85
  - [`clasp open-script](#open)
86
86
  - [`clasp list-deployments`](#deployments)
87
87
  - [`clasp create-deployment [--versionNumber <version>] [--description <description>] [--deploymentId <id>]`](#deploy)
@@ -111,29 +111,30 @@ robust support for Typescript features along with ESM module and NPM package sup
111
111
 
112
112
  There are several template projects on GitHub that show how to transform Typescript code into Apps Script that are all excellent choices.
113
113
 
114
- * https://github.com/sqrrrl/apps-script-typescript-rollup-starter
115
114
  * https://github.com/WildH0g/apps-script-engine-template
116
115
  * https://github.com/tomoyanakano/clasp-typescript-template
116
+ * https://github.com/google/aside
117
+ * https://github.com/sqrrrl/apps-script-typescript-rollup-starter
117
118
 
118
119
 
119
120
  #### Command renames
120
121
 
121
- Clasp 3.x introdces some breaking changes from 2.x. For common use cases these changes should not impact usage, but some lesser used commands have been restructured and renamed to improve consistency.
122
+ Clasp 3.x introduces some breaking changes from 2.x. For common use cases these changes should not impact usage, but some lesser used commands have been restructured and renamed to improve consistency.
122
123
 
123
124
  | 2.x | 3.x |
124
125
  |----------------------------|----------------------------------------|
125
- |open | open-script |
126
- |open --web | open-web-app |
127
- |open --addon | open-container |
128
- |open --creds | open-credentials-setup |
129
- |login --creds <file> | login -u <name> --creds <file> |
130
- |logs --open | open-logs |
131
- |logs --setup | N/A |
132
- |apis --open | open-api-console |
133
- |apis enable <api> | enable-api <api> |
134
- |apis disable <api> | disable-api <api> |
135
- |settings | N/A |
136
- |----------------------------|----------------------------------------|
126
+ |`open` | `open-script` |
127
+ |`open --web` | `open-web-app` |
128
+ |`open --addon` | `open-container` |
129
+ |`open --creds` | `open-credentials-setup` |
130
+ |`login --creds <file>` | `login -u <name> --creds <file>` |
131
+ |`logs --open` | `open-logs` |
132
+ |`logs --setup` | N/A |
133
+ |`apis --open` | `open-api-console` |
134
+ |`apis enable <api>` | `enable-api <api>` |
135
+ |`apis disable <api>` | `disable-api <api>` |
136
+ |`deploy -i <id>` | `update-deployment <id>` |
137
+ |`settings` | N/A |
137
138
 
138
139
  Other commands have also been renamed but retain aliases for compatibility.
139
140
 
@@ -182,6 +183,13 @@ https://www.googleapis.com/auth/userinfo.profile
182
183
  https://www.googleapis.com/auth/cloud-platform
183
184
  ```
184
185
 
186
+ ### Allow-list clasp
187
+
188
+ If your organization restricts authorization for third-party apps, you may either:
189
+
190
+ * Request your admin allow-list clasp's client id `1072944905499-vm2v2i5dvn0a0d2o4ca36i1vge8cvbn0.apps.googleusercontent.com`
191
+ * Set up an internal-only GCP project for clasp as described in the previous section.
192
+
185
193
  ### Service accounts
186
194
 
187
195
  Use the `--adc` option on any command to read credentials from the environemtn using Google Cloud's [application default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) mechanism.
@@ -263,14 +271,39 @@ You must [associate Google Script project with Google Cloud Platform](https://gi
263
271
 
264
272
  Even if you do not set this manually, clasp will ask this via a prompt to you at the required time.
265
273
 
266
- ### `fileExtension` (optional)
274
+ ### `fileExtension` (deprecated, optional)
267
275
 
268
276
  Specifies the file extension for **local** script files in your Apps Script project.
269
277
 
278
+ ### `scriptExtensions` (optional)
279
+
280
+ Specifies the file extensions for **local** script files in your Apps Script project. May be a string or array of strings. Files matching the extension will be considered scripts files.
281
+
282
+ When pulling files, the first extension listed is used to write files.
283
+
284
+ Defaults to `[".js", ".gs"]`
285
+
286
+ ### `htmlExtensions` (optional)
287
+
288
+ Specifies the file extensions for **local** HTML files in your Apps Script project. May be a string or array of strings. Files matching the extension will be considered HTML files.
289
+
290
+ When pulling files, the first extension listed is used to write files.
291
+
292
+ Defaults to `[".html"]`
293
+
270
294
  ### `filePushOrder` (optional)
271
295
 
272
- Specifies the files that should be pushed first, useful for scripts that rely on order of execution. All other files are pushed after this list of files.
296
+ Specifies the files that should be pushed first, useful for scripts that rely on order of execution. All other files are pushed after this list of files, sorted by name.
297
+
298
+ Note that file paths are relative to directory containing .clasp.json. If `rootDir` is also set, any files listed should include that path as well.
273
299
 
300
+ ### `skipSubdirectories` (optional)
301
+
302
+ For backwards compatibility with previous behavior where subdirectories
303
+ are ignored if a `.claspignore` file is not present. Clasp provides default
304
+ ignore rules, making the previous warning and behavior confusing. If you
305
+ need to force clasp to ignore subdirectories and do not want to construct
306
+ a `.claspignore` file, set this option to true.
274
307
 
275
308
  ## Reference
276
309
 
@@ -290,7 +323,6 @@ Logs the user in. Saves the client credentials to a `.clasprc.json` file in the
290
323
 
291
324
  - `--no-localhost`: Do not run a local server, manually enter code instead.
292
325
  - `--creds <file>`: Use custom credentials used for `clasp run`. Saves a `.clasprc.json` file to current working directory. This file should be private!
293
- - `--status`: Print who you are currently logged in as, if anyone.
294
326
  - `--redirect-port <port>`: Specify a custom port for the local redirect server during the login process. Useful for environments where a specific port is required.
295
327
 
296
328
  #### Examples
@@ -453,6 +485,19 @@ To update/redeploy an existing deployment, provide the deployment ID.
453
485
  - `clasp create-deployment --deploymentId abcd1234` (redeploy and create new version)
454
486
  - `clasp create-deployment -V 7 -d "Updates sidebar logo." -i abdc1234`
455
487
 
488
+ ### Redeploy
489
+
490
+ Updates an existing deployment. Same as `create-deployment -i id`.
491
+
492
+ #### Options
493
+
494
+ - `-V <version>` `--versionNumber <version>`: The project version to deploy at.
495
+ - `-d <description>` `--description <description>`: The deployment description.
496
+
497
+ #### Examples
498
+
499
+ - `clasp update-deployment abcd1234` (redeploy and create new version)
500
+
456
501
  ### Undeploy
457
502
 
458
503
  Undeploys a deployment of a script.
@@ -1,15 +1,18 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import Debug from 'debug';
4
5
  import { GoogleAuth, OAuth2Client } from 'google-auth-library';
5
6
  import { google } from 'googleapis';
6
7
  import { FileCredentialStore } from './file_credential_store.js';
7
8
  import { LocalServerAuthorizationCodeFlow } from './localhost_auth_code_flow.js';
8
9
  import { ServerlessAuthorizationCodeFlow } from './serverless_auth_code_flow.js';
10
+ const debug = Debug('clasp:auth');
9
11
  export async function initAuth(options) {
10
12
  var _a, _b, _c;
11
13
  const authFilePath = (_a = options.authFilePath) !== null && _a !== void 0 ? _a : path.join(os.homedir(), '.clasprc.json');
12
14
  const credentialStore = new FileCredentialStore(authFilePath);
15
+ debug('Initializng auth from %s', options.authFilePath);
13
16
  if (options.useApplicationDefaultCredentials) {
14
17
  const credentials = await createApplicationDefaultCredentials();
15
18
  return {
@@ -26,15 +29,23 @@ export async function initAuth(options) {
26
29
  };
27
30
  }
28
31
  export async function getUserInfo(credentials) {
32
+ debug('Fetching user info');
29
33
  const api = google.oauth2('v2');
30
- const res = await api.userinfo.get({ auth: credentials });
31
- if (res.status !== 200) {
34
+ try {
35
+ const res = await api.userinfo.get({ auth: credentials });
36
+ if (!res.data) {
37
+ debug('No user info returned');
38
+ return undefined;
39
+ }
40
+ return {
41
+ email: res.data.email,
42
+ id: res.data.id,
43
+ };
44
+ }
45
+ catch (err) {
46
+ debug('Error while fetching userinfo: %O', err);
32
47
  return undefined;
33
48
  }
34
- return {
35
- email: res.data.email,
36
- id: res.data.id,
37
- };
38
49
  }
39
50
  /**
40
51
  * Creates an an unauthorized oauth2 client given the client secret file. If no path is provided,
@@ -57,13 +68,16 @@ export async function getAuthorizedOAuth2Client(store, userKey) {
57
68
  if (!userKey) {
58
69
  userKey = 'default';
59
70
  }
71
+ debug('Loading credentials for user %s', userKey);
60
72
  const savedCredentials = await store.load(userKey);
61
73
  if (!savedCredentials) {
74
+ debug('No saved credentials found.');
62
75
  return undefined;
63
76
  }
64
77
  const client = new GoogleAuth().fromJSON(savedCredentials);
65
78
  client.setCredentials(savedCredentials);
66
79
  client.on('tokens', async (tokens) => {
80
+ debug('Saving refreshed token for user %s', userKey);
67
81
  const refreshedCredentials = {
68
82
  ...savedCredentials,
69
83
  expiry_date: tokens.expiry_date,
@@ -85,13 +99,16 @@ export async function getAuthorizedOAuth2Client(store, userKey) {
85
99
  export async function authorize(options) {
86
100
  let flow;
87
101
  if (options.noLocalServer) {
102
+ debug('Starting auth with serverless flow');
88
103
  flow = new ServerlessAuthorizationCodeFlow(options.oauth2Client);
89
104
  }
90
105
  else {
106
+ debug('Starting auth with local server flow');
91
107
  flow = new LocalServerAuthorizationCodeFlow(options.oauth2Client);
92
108
  }
93
109
  const client = await flow.authorize(options.scopes);
94
110
  await saveOauthClientCredentials(options.store, options.userKey, client);
111
+ debug('Auth complete');
95
112
  return client;
96
113
  }
97
114
  async function saveOauthClientCredentials(store, userKey, oauth2Client) {
@@ -110,8 +127,10 @@ async function saveOauthClientCredentials(store, userKey, oauth2Client) {
110
127
  access_token: tokens.access_token,
111
128
  id_token: tokens.access_token,
112
129
  };
130
+ debug('Saving refreshed credentials for user %s', userKey);
113
131
  await store.save(userKey, refreshedCredentials);
114
132
  });
133
+ debug('Saving credentials for user %s', userKey);
115
134
  await store.save(userKey, savedCredentials);
116
135
  }
117
136
  /**
@@ -120,6 +139,7 @@ async function saveOauthClientCredentials(store, userKey, oauth2Client) {
120
139
  * @returns
121
140
  */
122
141
  function createOauthClient(clientSecretPath) {
142
+ debug('Creating new oauth client from %s', clientSecretPath);
123
143
  if (!clientSecretPath) {
124
144
  throw new Error('Invalid credentials');
125
145
  }
@@ -134,11 +154,13 @@ function createOauthClient(clientSecretPath) {
134
154
  throw new Error('No localhost redirect URL found');
135
155
  }
136
156
  // create an oAuth client to authorize the API call
137
- return new OAuth2Client({
157
+ const client = new OAuth2Client({
138
158
  clientId: keys.client_id,
139
159
  clientSecret: keys.client_secret,
140
160
  redirectUri: redirectUrl,
141
161
  });
162
+ debug('Created built-in oauth client, id: %s', client._clientId);
163
+ return client;
142
164
  }
143
165
  /**
144
166
  * Creates an aunthorized oauth2 client using the default id & secret.
@@ -147,11 +169,13 @@ function createOauthClient(clientSecretPath) {
147
169
  */
148
170
  function createDefaultOAuthClient() {
149
171
  // Default client
150
- return new OAuth2Client({
172
+ const client = new OAuth2Client({
151
173
  clientId: '1072944905499-vm2v2i5dvn0a0d2o4ca36i1vge8cvbn0.apps.googleusercontent.com',
152
174
  clientSecret: 'v6V3fKV_zWU7iw1DrpO1rknX',
153
175
  redirectUri: 'http://localhost',
154
176
  });
177
+ debug('Created built-in oauth client, id: %s', client._clientId);
178
+ return client;
155
179
  }
156
180
  export async function createApplicationDefaultCredentials() {
157
181
  const defaultCreds = await new GoogleAuth({
@@ -170,6 +194,7 @@ export async function createApplicationDefaultCredentials() {
170
194
  }).getClient();
171
195
  // Remove this check after https://github.com/googleapis/google-auth-library-nodejs/issues/1677 fixed
172
196
  if (defaultCreds instanceof OAuth2Client) {
197
+ debug('Created service account credentials, id: %s', defaultCreds._clientId);
173
198
  return defaultCreds;
174
199
  }
175
200
  return undefined;
@@ -1,8 +1,8 @@
1
1
  import { createServer } from 'http';
2
+ import open from 'open';
2
3
  import enableDestroy from 'server-destroy';
3
4
  import { intl } from '../intl.js';
4
5
  import { AuthorizationCodeFlow, parseAuthResponseUrl } from './auth_code_flow.js';
5
- import open from 'open';
6
6
  export class LocalServerAuthorizationCodeFlow extends AuthorizationCodeFlow {
7
7
  constructor(oauth2client) {
8
8
  super(oauth2client);
@@ -56,7 +56,7 @@ export const command = new Command('clone-script')
56
56
  return files;
57
57
  });
58
58
  files.forEach(f => console.log(`└─ ${f.localPath}`));
59
- const successMessage = intl.formatMessage({ id: "Hw9Gqn", defaultMessage: [{ type: 0, value: "Warning: files in subfolder are not accounted for unless you set a .claspignore file. Cloned " }, { type: 6, value: "count", options: { "=0": { value: [{ type: 0, value: "no files." }] }, one: { value: [{ type: 0, value: "one file." }] }, other: { value: [{ type: 7 }, { type: 0, value: " files" }] } }, offset: 0, pluralType: "cardinal" }, { type: 0, value: "." }] }, {
59
+ const successMessage = intl.formatMessage({ id: "XABSyD", defaultMessage: [{ type: 0, value: "Cloned " }, { type: 6, value: "count", options: { "=0": { value: [{ type: 0, value: "no files." }] }, one: { value: [{ type: 0, value: "one file." }] }, other: { value: [{ type: 7 }, { type: 0, value: " files" }] } }, offset: 0, pluralType: "cardinal" }, { type: 0, value: "." }] }, {
60
60
  count: files.length,
61
61
  });
62
62
  console.log(successMessage);
@@ -11,7 +11,7 @@ const DRIVE_FILE_MIMETYPES = {
11
11
  slides: 'application/vnd.google-apps.presentation',
12
12
  };
13
13
  export const command = new Command('create-script')
14
- .command('create')
14
+ .alias('create')
15
15
  .description('Create a script')
16
16
  .option('--type <type>', 'Creates a new Apps Script project attached to a new Document, Spreadsheet, Presentation, Form, or as a standalone script, web app, or API.', 'standalone')
17
17
  .option('--title <title>', 'The project title.')
@@ -37,19 +37,26 @@ export const command = new Command('create-script')
37
37
  this.error(msg);
38
38
  }
39
39
  const spinnerMsg = intl.formatMessage({ id: "TMfpGK", defaultMessage: [{ type: 0, value: "Creating script..." }] });
40
- const { parentId } = await withSpinner(spinnerMsg, async () => await clasp.project.createWithContainer(name, mimeType));
41
- const url = `https://drive.google.com/open?id=${parentId}`;
42
- const successMessage = intl.formatMessage({ id: "11VZzp", defaultMessage: [{ type: 0, value: "Created new container: [url}" }] }, {
43
- url,
40
+ const { parentId, scriptId } = await withSpinner(spinnerMsg, async () => await clasp.project.createWithContainer(name, mimeType));
41
+ const parentUrl = `https://drive.google.com/open?id=${parentId}`;
42
+ const scriptUrl = `https://script.google.com/d/${scriptId}/edit`;
43
+ const successMessage = intl.formatMessage({ id: "yf9wXJ", defaultMessage: [{ type: 0, value: "Created new document: " }, { type: 1, value: "parentUrl" }, { type: 1, value: "br" }, { type: 0, value: "Created new script: " }, { type: 1, value: "scriptUrl" }] }, {
44
+ parentUrl,
45
+ scriptUrl,
46
+ br: '\n',
44
47
  });
45
48
  console.log(successMessage);
46
49
  }
47
50
  else {
48
51
  const spinnerMsg = intl.formatMessage({ id: "TMfpGK", defaultMessage: [{ type: 0, value: "Creating script..." }] });
49
52
  const scriptId = await withSpinner(spinnerMsg, async () => await clasp.project.createScript(name, parentId));
50
- const url = `https://script.google.com/d/${scriptId}/edit`;
51
- const successMessage = intl.formatMessage({ id: "NUzw0t", defaultMessage: [{ type: 0, value: "Created new script: " }, { type: 1, value: "url" }] }, {
52
- url,
53
+ const parentUrl = `https://drive.google.com/open?id=${parentId}`;
54
+ const scriptUrl = `https://script.google.com/d/${scriptId}/edit`;
55
+ const successMessage = intl.formatMessage({ id: "0a429S", defaultMessage: [{ type: 0, value: "Created new script: " }, { type: 1, value: "scriptUrl" }, { type: 5, value: "parentId", options: { undefined: { value: [] }, other: { value: [{ type: 1, value: "br" }, { type: 0, value: "Bound to document: " }, { type: 1, value: "parentUrl" }] } } }] }, {
56
+ parentId,
57
+ parentUrl,
58
+ scriptUrl,
59
+ br: '\n',
53
60
  });
54
61
  console.log(successMessage);
55
62
  }
@@ -60,7 +67,7 @@ export const command = new Command('create-script')
60
67
  return files;
61
68
  });
62
69
  files.forEach(f => console.log(`└─ ${f.localPath}`));
63
- const successMessage = intl.formatMessage({ id: "Hw9Gqn", defaultMessage: [{ type: 0, value: "Warning: files in subfolder are not accounted for unless you set a .claspignore file. Cloned " }, { type: 6, value: "count", options: { "=0": { value: [{ type: 0, value: "no files." }] }, one: { value: [{ type: 0, value: "one file." }] }, other: { value: [{ type: 7 }, { type: 0, value: " files" }] } }, offset: 0, pluralType: "cardinal" }, { type: 0, value: "." }] }, {
70
+ const successMessage = intl.formatMessage({ id: "XABSyD", defaultMessage: [{ type: 0, value: "Cloned " }, { type: 6, value: "count", options: { "=0": { value: [{ type: 0, value: "no files." }] }, one: { value: [{ type: 0, value: "one file." }] }, other: { value: [{ type: 7 }, { type: 0, value: " files" }] } }, offset: 0, pluralType: "cardinal" }, { type: 0, value: "." }] }, {
64
71
  count: files.length,
65
72
  });
66
73
  console.log(successMessage);
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { INCLUDE_USER_HINT_IN_URL } from '../experiments.js';
2
3
  import { assertGcpProjectConfigured, maybePromptForProjectId, openUrl } from './utils.js';
3
4
  export const command = new Command('open-api-console')
4
5
  .description('Open the API console for the current project.')
@@ -6,6 +7,11 @@ export const command = new Command('open-api-console')
6
7
  const clasp = this.opts().clasp;
7
8
  const projectId = await maybePromptForProjectId(clasp);
8
9
  assertGcpProjectConfigured(clasp);
9
- const url = `https://console.developers.google.com/apis/dashboard?project=${projectId}`;
10
- await openUrl(url);
10
+ const url = new URL('https://console.developers.google.com/apis/dashboard');
11
+ url.searchParams.set('project', projectId !== null && projectId !== void 0 ? projectId : '');
12
+ if (INCLUDE_USER_HINT_IN_URL) {
13
+ const userHint = await clasp.authorizedUser();
14
+ url.searchParams.set('authUser', userHint !== null && userHint !== void 0 ? userHint : '');
15
+ }
16
+ await openUrl(url.toString());
11
17
  });
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { INCLUDE_USER_HINT_IN_URL } from '../experiments.js';
2
3
  import { intl } from '../intl.js';
3
4
  import { openUrl } from './utils.js';
4
5
  export const command = new Command('open-container')
@@ -10,6 +11,11 @@ export const command = new Command('open-container')
10
11
  const msg = intl.formatMessage({ id: "eXBzoP", defaultMessage: [{ type: 0, value: "Parent ID not set, unable to open document." }] });
11
12
  this.error(msg);
12
13
  }
13
- const url = `https://drive.google.com/open?id=${parentId}`;
14
- await openUrl(url);
14
+ const url = new URL('https://drive.google.com/open');
15
+ url.searchParams.set('id', parentId);
16
+ if (INCLUDE_USER_HINT_IN_URL) {
17
+ const userHint = await clasp.authorizedUser();
18
+ url.searchParams.set('authUser', userHint !== null && userHint !== void 0 ? userHint : '');
19
+ }
20
+ await openUrl(url.toString());
15
21
  });
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { INCLUDE_USER_HINT_IN_URL } from '../experiments.js';
2
3
  import { assertGcpProjectConfigured, maybePromptForProjectId, openUrl } from './utils.js';
3
4
  export const command = new Command('open-credentials-setup')
4
5
  .description("Open credentials page for the script's GCP project")
@@ -6,6 +7,11 @@ export const command = new Command('open-credentials-setup')
6
7
  const clasp = this.opts().clasp;
7
8
  const projectId = await maybePromptForProjectId(clasp);
8
9
  assertGcpProjectConfigured(clasp);
9
- const url = `https://console.developers.google.com/apis/credentials?project=${projectId}`;
10
- await openUrl(url);
10
+ const url = new URL('https://console.developers.google.com/apis/credentials');
11
+ url.searchParams.set('project', projectId !== null && projectId !== void 0 ? projectId : '');
12
+ if (INCLUDE_USER_HINT_IN_URL) {
13
+ const userHint = await clasp.authorizedUser();
14
+ url.searchParams.set('authUser', userHint !== null && userHint !== void 0 ? userHint : '');
15
+ }
16
+ await openUrl(url.toString());
11
17
  });
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { INCLUDE_USER_HINT_IN_URL } from '../experiments.js';
2
3
  import { assertGcpProjectConfigured, maybePromptForProjectId, openUrl } from './utils.js';
3
4
  export const command = new Command('open-logs')
4
5
  .description('Open logs in the developer console')
@@ -6,6 +7,12 @@ export const command = new Command('open-logs')
6
7
  const clasp = this.opts().clasp;
7
8
  const projectId = await maybePromptForProjectId(clasp);
8
9
  assertGcpProjectConfigured(clasp);
9
- const url = `https://console.cloud.google.com/logs/viewer?project=${projectId}&resource=app_script_function`;
10
- await openUrl(url);
10
+ const url = new URL('https://console.cloud.google.com/logs/viewer');
11
+ url.searchParams.set('project', projectId !== null && projectId !== void 0 ? projectId : '');
12
+ url.searchParams.set('resource', 'app_script_function');
13
+ if (INCLUDE_USER_HINT_IN_URL) {
14
+ const userHint = await clasp.authorizedUser();
15
+ url.searchParams.set('authUser', userHint !== null && userHint !== void 0 ? userHint : '');
16
+ }
17
+ await openUrl(url.toString());
11
18
  });
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { INCLUDE_USER_HINT_IN_URL } from '../experiments.js';
2
3
  import { intl } from '../intl.js';
3
4
  import { openUrl } from './utils.js';
4
5
  export const command = new Command('open-script')
@@ -13,6 +14,10 @@ export const command = new Command('open-script')
13
14
  const msg = intl.formatMessage({ id: "RXEA+0", defaultMessage: [{ type: 0, value: "Script ID not set, unable to open IDE." }] });
14
15
  this.error(msg);
15
16
  }
16
- const url = `https://script.google.com/d/${scriptId}/edit`;
17
- await openUrl(url);
17
+ const url = new URL(`https://script.google.com/d/${scriptId}/edit`);
18
+ if (INCLUDE_USER_HINT_IN_URL) {
19
+ const userHint = await clasp.authorizedUser();
20
+ url.searchParams.set('authUser', userHint !== null && userHint !== void 0 ? userHint : '');
21
+ }
22
+ await openUrl(url.toString());
18
23
  });
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import inquirer from 'inquirer';
3
+ import { INCLUDE_USER_HINT_IN_URL } from '../experiments.js';
3
4
  import { intl } from '../intl.js';
4
5
  import { ellipsize, isInteractive, openUrl } from './utils.js';
5
6
  export const command = new Command('open-web-app')
@@ -39,7 +40,7 @@ export const command = new Command('open-web-app')
39
40
  deploymentId = answer.deployment;
40
41
  }
41
42
  if (!deploymentId) {
42
- const msg = intl.formatMessage({ id: "j847fH", defaultMessage: [{ type: 0, value: "Deployment ID is requrired." }] });
43
+ const msg = intl.formatMessage({ id: "VJZ9X5", defaultMessage: [{ type: 0, value: "Deployment ID is required." }] });
43
44
  this.error(msg);
44
45
  }
45
46
  const entryPoints = (_a = (await clasp.project.entryPoints(deploymentId))) !== null && _a !== void 0 ? _a : [];
@@ -51,6 +52,10 @@ export const command = new Command('open-web-app')
51
52
  const msg = intl.formatMessage({ id: "Kfeimc", defaultMessage: [{ type: 0, value: "No web app entry point found." }] });
52
53
  this.error(msg);
53
54
  }
54
- const url = webAppEntry.webApp.url;
55
- await openUrl(url);
55
+ const url = new URL(webAppEntry.webApp.url);
56
+ if (INCLUDE_USER_HINT_IN_URL) {
57
+ const userHint = await clasp.authorizedUser();
58
+ url.searchParams.set('authUser', userHint !== null && userHint !== void 0 ? userHint : '');
59
+ }
60
+ await openUrl(url.toString());
56
61
  });
@@ -18,6 +18,7 @@ export const command = new Command('push')
18
18
  if (!force) {
19
19
  const msg = intl.formatMessage({ id: "TItFfu", defaultMessage: [{ type: 0, value: "Skipping push." }] });
20
20
  console.log(msg);
21
+ return;
21
22
  }
22
23
  }
23
24
  const spinnerMsg = intl.formatMessage({ id: "qUq++d", defaultMessage: [{ type: 0, value: "Pushing files..." }] });
@@ -47,7 +48,7 @@ export const command = new Command('push')
47
48
  const msg = intl.formatMessage({ id: "m/C0lF", defaultMessage: [{ type: 0, value: "Waiting for changes..." }] });
48
49
  console.log(msg);
49
50
  };
50
- const stopWatching = clasp.files.watchLocalFiles(onReady, async (paths) => {
51
+ const stopWatching = await clasp.files.watchLocalFiles(onReady, async (paths) => {
51
52
  if (!(await onChange(paths))) {
52
53
  stopWatching();
53
54
  }
@@ -39,7 +39,7 @@ export const command = new Command('run-function')
39
39
  console.error(`${chalk.red(msg)}`, errorMessage, scriptStackTraceElements || []);
40
40
  return;
41
41
  }
42
- if (response && response.result) {
42
+ if (response && response.result !== undefined) {
43
43
  console.log(response.result);
44
44
  }
45
45
  else {
@@ -41,6 +41,7 @@ export const command = new Command('tail-logs')
41
41
  await maybePromptForProjectId(clasp);
42
42
  }
43
43
  assertGcpProjectConfigured(clasp);
44
+ console.log('PAST', clasp.project.projectId);
44
45
  await fetchAndPrintLogs();
45
46
  if (watch) {
46
47
  const POLL_INTERVAL = 6000; // 6s
@@ -62,6 +63,9 @@ function formatEntry(entry, options) {
62
63
  if (!resource) {
63
64
  return undefined;
64
65
  }
66
+ if (!timestamp) {
67
+ return undefined;
68
+ }
65
69
  let functionName = (_b = (_a = resource.labels) === null || _a === void 0 ? void 0 : _a['function_name']) !== null && _b !== void 0 ? _b : 'N/A';
66
70
  let payloadData = '';
67
71
  if (options.json) {
@@ -85,8 +89,18 @@ function formatEntry(entry, options) {
85
89
  const coloredSeverity = `${severityColor[severity](severity) || severity}`.padEnd(20);
86
90
  functionName = functionName.padEnd(15);
87
91
  payloadData = payloadData.padEnd(20);
92
+ const localizedTime = getLocalISODateTime(new Date(timestamp));
88
93
  if (options.simplified) {
89
94
  return `${coloredSeverity} ${functionName} ${payloadData}`;
90
95
  }
91
- return `${coloredSeverity} ${timestamp} ${functionName} ${payloadData}`;
96
+ return `${coloredSeverity} ${localizedTime} ${functionName} ${payloadData}`;
97
+ }
98
+ function getLocalISODateTime(date) {
99
+ const year = date.getFullYear();
100
+ const month = String(date.getMonth() + 1).padStart(2, '0');
101
+ const day = String(date.getDate()).padStart(2, '0');
102
+ const hours = String(date.getHours()).padStart(2, '0');
103
+ const minutes = String(date.getMinutes()).padStart(2, '0');
104
+ const seconds = String(date.getSeconds()).padStart(2, '0');
105
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
92
106
  }
@@ -0,0 +1,36 @@
1
+ import { Command } from 'commander';
2
+ import { intl } from '../intl.js';
3
+ import { withSpinner } from './utils.js';
4
+ export const command = new Command('update-deployment')
5
+ .alias('redeploy')
6
+ .argument('<deploymentId>')
7
+ .description('Updates a deployment for a project to a new version')
8
+ .option('-V, --versionNumber <version>', 'The project version')
9
+ .option('-d, --description <description>', 'The deployment description')
10
+ .action(async function (deploymentId, options) {
11
+ var _a, _b, _c;
12
+ const clasp = this.opts().clasp;
13
+ const description = (_a = options.description) !== null && _a !== void 0 ? _a : '';
14
+ const versionNumber = options.versionNumber ? Number(options.versionNumber) : undefined;
15
+ if (!deploymentId) {
16
+ const msg = intl.formatMessage({ id: "OXJvuR", defaultMessage: [{ type: 0, value: "Deployment ID is required to redeploy." }] });
17
+ this.error(msg);
18
+ }
19
+ try {
20
+ const spinnerMsg = intl.formatMessage({ id: "oL8t7p", defaultMessage: [{ type: 0, value: "Deploying project..." }] });
21
+ const deployment = await withSpinner(spinnerMsg, async () => {
22
+ return await clasp.project.deploy(description, deploymentId, versionNumber);
23
+ });
24
+ const successMessage = intl.formatMessage({ id: "wWBE7L", defaultMessage: [{ type: 0, value: "Redeployed " }, { type: 1, value: "deploymentId" }, { type: 0, value: " " }, { type: 5, value: "version", options: { undefined: { value: [{ type: 0, value: "@HEAD" }] }, other: { value: [{ type: 0, value: "@" }, { type: 1, value: "version" }] } } }] }, {
25
+ deploymentId: deployment.deploymentId,
26
+ version: (_b = deployment.deploymentConfig) === null || _b === void 0 ? void 0 : _b.versionNumber,
27
+ });
28
+ console.log(successMessage);
29
+ }
30
+ catch (error) {
31
+ if (((_c = error.cause) === null || _c === void 0 ? void 0 : _c.code) === 'INVALID_ARGUMENT') {
32
+ this.error(error.cause.message);
33
+ }
34
+ throw error;
35
+ }
36
+ });
@@ -3,14 +3,14 @@ import inquirer from 'inquirer';
3
3
  import open from 'open';
4
4
  import ora from 'ora';
5
5
  import { intl } from '../intl.js';
6
- export async function assertScriptConfigured(clasp) {
6
+ export function assertScriptConfigured(clasp) {
7
7
  if (clasp.project.scriptId) {
8
8
  return;
9
9
  }
10
10
  const msg = intl.formatMessage({ id: "2IuvqO", defaultMessage: [{ type: 0, value: "Script ID is not set, unable to continue." }] });
11
11
  throw new Error(msg);
12
12
  }
13
- export async function assertGcpProjectConfigured(clasp) {
13
+ export function assertGcpProjectConfigured(clasp) {
14
14
  if (clasp.project.projectId) {
15
15
  return;
16
16
  }
@@ -61,13 +61,14 @@ export function ellipsize(value, length) {
61
61
  }
62
62
  // Exporting and wrapping to allow it to be toggled in tests
63
63
  export const claspEnv = {
64
- isInteractive: process.stdout.isTTY
64
+ isInteractive: process.stdout.isTTY,
65
+ isBrowserPresent: process.stdout.isTTY,
65
66
  };
66
67
  export function isInteractive() {
67
68
  return claspEnv.isInteractive;
68
69
  }
69
70
  export async function openUrl(url) {
70
- if (!isInteractive()) {
71
+ if (!claspEnv.isBrowserPresent) {
71
72
  const msg = intl.formatMessage({ id: "kvR0OI", defaultMessage: [{ type: 0, value: "Open " }, { type: 1, value: "url" }, { type: 0, value: " in your browser to continue." }] }, {
72
73
  url,
73
74
  });
@@ -4,11 +4,13 @@ import { findUpSync } from 'find-up';
4
4
  import fs from 'fs/promises';
5
5
  import splitLines from 'split-lines';
6
6
  import stripBom from 'strip-bom';
7
+ import { getUserInfo } from '../auth/auth.js';
7
8
  import { Files } from './files.js';
8
9
  import { Functions } from './functions.js';
9
10
  import { Logs } from './logs.js';
10
11
  import { Project } from './project.js';
11
12
  import { Services } from './services.js';
13
+ import { ensureStringArray } from './utils.js';
12
14
  const debug = Debug('clasp:core');
13
15
  const DEFAULT_CLASP_IGNORE = [
14
16
  '**/**',
@@ -30,6 +32,19 @@ export class Clasp {
30
32
  this.logs = new Logs(options);
31
33
  this.functions = new Functions(options);
32
34
  }
35
+ async authorizedUser() {
36
+ if (!this.options.credentials) {
37
+ return undefined;
38
+ }
39
+ try {
40
+ const user = await getUserInfo(this.options.credentials);
41
+ return user === null || user === void 0 ? void 0 : user.id;
42
+ }
43
+ catch (err) {
44
+ debug('Unable to fetch user info, %O', err);
45
+ }
46
+ return undefined;
47
+ }
33
48
  withScriptId(scriptId) {
34
49
  if (this.options.project) {
35
50
  throw new Error('Science project already set, create new instance instead');
@@ -65,7 +80,8 @@ export async function initClaspInstance(options) {
65
80
  ignoreFilePath: ignoreFile,
66
81
  ignorePatterns: ignoreRules,
67
82
  filePushOrder: [],
68
- fileExtension: 'js',
83
+ skipSubdirectories: false,
84
+ fileExtensions: readFileExtensions({}),
69
85
  },
70
86
  });
71
87
  }
@@ -74,7 +90,7 @@ export async function initClaspInstance(options) {
74
90
  const ignoreRules = await loadIgnoreFileOrDefaults(ignoreFile);
75
91
  const content = await fs.readFile(projectRoot.configPath, { encoding: 'utf8' });
76
92
  const config = JSON.parse(content);
77
- const fileExtension = config.fileExtension || 'js';
93
+ const fileExtensions = readFileExtensions(config);
78
94
  const filePushOrder = config.filePushOrder || [];
79
95
  const contentDir = path.resolve(projectRoot.rootDir, config.srcDir || config.rootDir || '.');
80
96
  return new Clasp({
@@ -86,15 +102,46 @@ export async function initClaspInstance(options) {
86
102
  ignoreFilePath: ignoreFile,
87
103
  ignorePatterns: ignoreRules,
88
104
  filePushOrder: filePushOrder,
89
- fileExtension: fileExtension,
105
+ fileExtensions: fileExtensions,
106
+ skipSubdirectories: config.ignoreSubdirectories,
90
107
  },
91
108
  project: {
92
109
  scriptId: config.scriptId,
93
110
  projectId: config.projectId,
94
- parentId: config.parentId,
111
+ parentId: firstValue(config.parentId),
95
112
  },
96
113
  });
97
114
  }
115
+ function readFileExtensions(config) {
116
+ let scriptExtensions = ['js', 'gs'];
117
+ let htmlExtensions = ['html'];
118
+ let jsonExtensions = ['json'];
119
+ if (config === null || config === void 0 ? void 0 : config.fileExtension) {
120
+ // legacy fileExtension setting
121
+ scriptExtensions = [config.fileExtension];
122
+ }
123
+ if (config === null || config === void 0 ? void 0 : config.scriptExtensions) {
124
+ scriptExtensions = ensureStringArray(config.scriptExtensions);
125
+ }
126
+ if (config === null || config === void 0 ? void 0 : config.htmlExtensions) {
127
+ htmlExtensions = ensureStringArray(config.htmlExtensions);
128
+ }
129
+ if (config === null || config === void 0 ? void 0 : config.jsonExtensions) {
130
+ jsonExtensions = ensureStringArray(config.jsonExtensions);
131
+ }
132
+ const fixupExtension = (ext) => {
133
+ ext = ext.toLowerCase().trim();
134
+ if (!ext.startsWith('.')) {
135
+ ext = `.${ext}`;
136
+ }
137
+ return ext;
138
+ };
139
+ return {
140
+ SERVER_JS: scriptExtensions.map(fixupExtension),
141
+ HTML: htmlExtensions.map(fixupExtension),
142
+ JSON: jsonExtensions.map(fixupExtension),
143
+ };
144
+ }
98
145
  async function findProjectRootdDir(configFilePath) {
99
146
  debug('Searching for project root');
100
147
  if (configFilePath) {
@@ -169,3 +216,9 @@ async function hasReadAccess(path) {
169
216
  }
170
217
  return true;
171
218
  }
219
+ function firstValue(values) {
220
+ if (Array.isArray(values) && values.length > 0) {
221
+ return values[0];
222
+ }
223
+ return values;
224
+ }
@@ -28,8 +28,15 @@ async function getLocalFiles(rootDir, ignorePatterns, recursive) {
28
28
  fdirBuilder = fdirBuilder.withMaxDepth(0);
29
29
  }
30
30
  const files = await fdirBuilder.crawl(rootDir).withPromise();
31
- const filteredFiles = micromatch.not(files, ignorePatterns, { dot: true });
32
- debug('Filtered %d files from ignore rules', files.length - filteredFiles.length);
31
+ let filteredFiles;
32
+ if (ignorePatterns && ignorePatterns.length) {
33
+ filteredFiles = micromatch.not(files, ignorePatterns, { dot: true });
34
+ debug('Filtered %d files from ignore rules', files.length - filteredFiles.length);
35
+ }
36
+ else {
37
+ debug('Ignore rules are empty, using all files.');
38
+ filteredFiles = files;
39
+ }
33
40
  filteredFiles.sort((a, b) => a.localeCompare(b));
34
41
  return filteredFiles[Symbol.iterator]();
35
42
  }
@@ -59,28 +66,36 @@ function createFilenameConflictChecker() {
59
66
  return file;
60
67
  };
61
68
  }
62
- function getFileType(fileName) {
63
- const extension = path.extname(fileName).toUpperCase();
64
- if (['.GS', '.JS'].includes(extension)) {
69
+ function getFileType(fileName, fileExtensions) {
70
+ var _a, _b, _c;
71
+ const originalExtension = path.extname(fileName);
72
+ const extension = originalExtension.toLowerCase();
73
+ if ((_a = fileExtensions['SERVER_JS']) === null || _a === void 0 ? void 0 : _a.includes(extension)) {
65
74
  return 'SERVER_JS';
66
75
  }
67
- if (extension === '.JSON' && path.basename(fileName) === 'appsscript.json') {
68
- return 'JSON';
69
- }
70
- if (extension === '.HTML') {
76
+ if ((_b = fileExtensions['HTML']) === null || _b === void 0 ? void 0 : _b.includes(extension)) {
71
77
  return 'HTML';
72
78
  }
79
+ if (((_c = fileExtensions['JSON']) === null || _c === void 0 ? void 0 : _c.includes(extension)) && path.basename(fileName, originalExtension) === 'appsscript') {
80
+ return 'JSON';
81
+ }
73
82
  return undefined;
74
83
  }
75
- function getFileExtension(type) {
84
+ function getFileExtension(type, fileExtensions) {
76
85
  // TODO - Include project setting override
86
+ const extensionFor = (type, defaultValue) => {
87
+ if (fileExtensions[type] && fileExtensions[type][0]) {
88
+ return fileExtensions[type][0];
89
+ }
90
+ return defaultValue;
91
+ };
77
92
  switch (type) {
78
93
  case 'SERVER_JS':
79
- return 'js';
94
+ return extensionFor('SERVER_JS', '.js');
80
95
  case 'JSON':
81
- return 'json';
96
+ return extensionFor('JSON', '.json');
82
97
  case 'HTML':
83
- return 'html';
98
+ return extensionFor('HTML', '.html');
84
99
  default:
85
100
  throw new Error('Invalid file type', {
86
101
  cause: {
@@ -96,11 +111,14 @@ function debounceFileChanges(callback, delayMs) {
96
111
  return function (path) {
97
112
  // Already tracked as changed, ignore
98
113
  if (collectedPaths.includes(path)) {
114
+ debug('Ignoring pending file change for path %s', path);
99
115
  return;
100
116
  }
117
+ debug('Debouncing change for path %s', path);
101
118
  collectedPaths.push(path);
102
119
  clearTimeout(timeoutId);
103
120
  timeoutId = setTimeout(() => {
121
+ debug('Firing debounced file');
104
122
  callback(collectedPaths);
105
123
  collectedPaths = [];
106
124
  }, delayMs);
@@ -119,15 +137,16 @@ export class Files {
119
137
  const contentDir = this.options.files.contentDir;
120
138
  const scriptId = this.options.project.scriptId;
121
139
  const script = google.script({ version: 'v1', auth: credentials });
140
+ const fileExtensionMap = this.options.files.fileExtensions;
122
141
  try {
123
142
  const requestOptions = { scriptId, versionNumber };
124
- debug('Fetchign script content, request %o', requestOptions);
143
+ debug('Fetching script content, request %o', requestOptions);
125
144
  const response = await script.projects.getContent(requestOptions);
126
145
  const files = (_a = response.data.files) !== null && _a !== void 0 ? _a : [];
127
146
  return files.map(f => {
128
147
  var _a, _b, _c;
129
- const ext = getFileExtension(f.type);
130
- const localPath = path.relative(process.cwd(), path.resolve(contentDir, `${f.name}.${ext}`));
148
+ const ext = getFileExtension(f.type, fileExtensionMap);
149
+ const localPath = path.relative(process.cwd(), path.resolve(contentDir, `${f.name}${ext}`));
131
150
  const file = {
132
151
  localPath: localPath,
133
152
  remotePath: (_a = f.name) !== null && _a !== void 0 ? _a : undefined,
@@ -148,17 +167,18 @@ export class Files {
148
167
  assertScriptConfigured(this.options);
149
168
  const contentDir = this.options.files.contentDir;
150
169
  const ignorePatterns = (_a = this.options.files.ignorePatterns) !== null && _a !== void 0 ? _a : [];
151
- const recursive = this.options.files.ignoreFilePath !== undefined;
170
+ const recursive = !this.options.files.skipSubdirectories;
152
171
  // Read all filenames as a flattened tree
153
172
  // Note: filePaths contain relative paths such as "test/bar.ts", "../../src/foo.js"
154
- const filelist = await getLocalFiles(contentDir, ignorePatterns, recursive);
173
+ const filelist = Array.from(await getLocalFiles(contentDir, ignorePatterns, recursive));
155
174
  const checkDuplicate = createFilenameConflictChecker();
175
+ const fileExtensionMap = this.options.files.fileExtensions;
156
176
  const files = await Promise.all(filelist.map(async (filename) => {
157
177
  const localPath = path.relative(process.cwd(), path.join(contentDir, filename));
158
178
  const resolvedPath = path.relative(contentDir, localPath);
159
179
  const parsedPath = path.parse(resolvedPath);
160
180
  let remotePath = path.format({ dir: normalizePath(parsedPath.dir), name: parsedPath.name });
161
- const type = getFileType(localPath);
181
+ const type = getFileType(localPath, fileExtensionMap);
162
182
  if (!type) {
163
183
  debug('Ignoring unsupported file %s', localPath);
164
184
  return undefined;
@@ -178,21 +198,37 @@ export class Files {
178
198
  const ignorePatterns = (_a = this.options.files.ignorePatterns) !== null && _a !== void 0 ? _a : [];
179
199
  const collector = debounceFileChanges(onFilesChanged, 500);
180
200
  const onChange = async (path) => {
201
+ debug('Have file changes: %s', path);
181
202
  collector(path);
182
203
  };
204
+ let matcher;
205
+ if (ignorePatterns && ignorePatterns.length) {
206
+ matcher = (file, stats) => {
207
+ if (!(stats === null || stats === void 0 ? void 0 : stats.isFile())) {
208
+ return false;
209
+ }
210
+ file = path.relative(this.options.files.projectRootDir, file);
211
+ const ignore = micromatch.not([file], ignorePatterns, { dot: true }).length === 0;
212
+ return ignore;
213
+ };
214
+ }
183
215
  const watcher = chokidar.watch(this.options.files.contentDir, {
184
216
  persistent: true,
185
217
  ignoreInitial: true,
186
218
  cwd: this.options.files.contentDir,
187
- ignored: file => {
188
- return micromatch.not([file], ignorePatterns, { dot: true }).length === 0;
189
- },
219
+ ignored: matcher,
190
220
  });
191
221
  watcher.on('ready', onReady); // Push on start
192
222
  watcher.on('add', onChange);
193
223
  watcher.on('change', onChange);
194
224
  watcher.on('unlink', onChange);
195
- return async () => await watcher.close();
225
+ watcher.on('error', err => {
226
+ debug('Unexpected error during watch: %O', err);
227
+ });
228
+ return async () => {
229
+ debug('Stopping watch');
230
+ await watcher.close();
231
+ };
196
232
  }
197
233
  async getChangedFiles() {
198
234
  const [localFiles, remoteFiles] = await Promise.all([this.collectLocalFiles(), this.fetchRemote()]);
@@ -309,6 +345,16 @@ export class Files {
309
345
  handleApiError(error);
310
346
  }
311
347
  }
348
+ checkMissingFilesFromPushOrder(pushedFiles) {
349
+ var _a;
350
+ const missingFiles = [];
351
+ for (const path of (_a = this.options.files.filePushOrder) !== null && _a !== void 0 ? _a : []) {
352
+ const wasPushed = pushedFiles.find(f => f.localPath === path);
353
+ if (!wasPushed) {
354
+ missingFiles.push(path);
355
+ }
356
+ }
357
+ }
312
358
  async pull(version) {
313
359
  debug('Pulling files');
314
360
  assertAuthenticated(this.options);
@@ -285,9 +285,13 @@ export class Project {
285
285
  const settings = {
286
286
  scriptId: this.options.project.scriptId,
287
287
  rootDir: srcDir,
288
+ parentId: this.options.project.parentId,
288
289
  projectId: this.options.project.projectId,
289
- fileExtension: this.options.files.fileExtension,
290
+ scriptExtensions: this.options.files.fileExtensions['SERVER_JS'],
291
+ htmlExtensions: this.options.files.fileExtensions['HTML'],
292
+ jsonExtensions: this.options.files.fileExtensions['JSON'],
290
293
  filePushOrder: [],
294
+ skipSubdirectories: this.options.files.skipSubdirectories,
291
295
  };
292
296
  await fs.writeFile(this.options.configFilePath, JSON.stringify(settings, null, 2));
293
297
  }
@@ -100,7 +100,7 @@ export function handleApiError(error) {
100
100
  throw new Error('Unexpected error', {
101
101
  cause: {
102
102
  code: 'UNEPECTED_ERROR',
103
- message: String(error),
103
+ message: new String(error),
104
104
  error: error,
105
105
  },
106
106
  });
@@ -119,3 +119,26 @@ export function handleApiError(error) {
119
119
  },
120
120
  });
121
121
  }
122
+ export function ensureStringArray(value) {
123
+ if (typeof value === 'string') {
124
+ return [value];
125
+ }
126
+ else if (Array.isArray(value)) {
127
+ // Ensure all elements in the array are strings.
128
+ if (value.every(item => typeof item === 'string')) {
129
+ return value;
130
+ }
131
+ else {
132
+ // Handle cases where the array contains non-string elements.
133
+ // You could throw an error, filter out non-strings, or convert them to strings.
134
+ // Example: filter out non-strings
135
+ return value.filter(item => typeof item === 'string');
136
+ }
137
+ }
138
+ else {
139
+ // Handle cases where the value is neither a string nor an array of strings.
140
+ // You could throw an error or return an empty array.
141
+ // Example: return an empty array
142
+ return [];
143
+ }
144
+ }
@@ -0,0 +1,12 @@
1
+ export function isEnabled(experimentName, defaultValue = false) {
2
+ const envVarName = `CLASP_${experimentName.toUpperCase()}`;
3
+ const envVarValue = process.env[envVarName];
4
+ if (envVarValue === undefined || envVarValue === null) {
5
+ return defaultValue;
6
+ }
7
+ if (envVarValue.toLowerCase() === 'true' || envVarValue === '1') {
8
+ return true;
9
+ }
10
+ return false;
11
+ }
12
+ export const INCLUDE_USER_HINT_IN_URL = isEnabled('enable_user_hints');
@@ -20,8 +20,11 @@
20
20
  */
21
21
  import Debug from 'debug';
22
22
  import loudRejection from 'loud-rejection';
23
+ import { CommanderError } from 'commander';
23
24
  import { makeProgram } from './commands/program.js';
24
25
  const debug = Debug('clasp:cli');
26
+ // Suppress warnings about punycode and other issues caused by dependencies
27
+ process.removeAllListeners('warning');
25
28
  // Ensure any unhandled exception won't go unnoticed
26
29
  loudRejection();
27
30
  const program = makeProgram();
@@ -32,7 +35,10 @@ try {
32
35
  }
33
36
  catch (error) {
34
37
  debug('Error: %O', error);
35
- if (error instanceof Error) {
38
+ if (error instanceof CommanderError) {
39
+ debug('Ignoring commander error, output already logged');
40
+ }
41
+ else if (error instanceof Error) {
36
42
  process.exitCode = 1;
37
43
  console.error(error.message);
38
44
  }
package/build/src/intl.js CHANGED
@@ -25,10 +25,12 @@ function loadMessages(_locale) {
25
25
  }
26
26
  const cache = createIntlCache();
27
27
  const locale = getLocale();
28
+ const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
28
29
  debug('Using locale: %s', locale);
29
30
  export const intl = createIntl({
30
31
  // Locale of the application
31
32
  locale,
33
+ timeZone: localTimeZone,
32
34
  defaultLocale: 'en',
33
35
  messages: loadMessages(locale),
34
36
  }, cache);
package/docs/run.md CHANGED
@@ -7,8 +7,8 @@
7
7
  To use `clasp run`, you need to complete 5 steps:
8
8
 
9
9
  - Set up the **Project ID** in your `.clasp.json` if missing.
10
- - Create an **OAuth Client ID** (Other). Download as `creds.json`.
11
- - `clasp login --creds creds.json` with this downloaded file.
10
+ - Create an **OAuth Client ID** of type `Desktop Application`. Download as `client_secret.json`.
11
+ - `clasp login --creds client_secret.json --user <key>` with this downloaded file.
12
12
  - Add the following to `appsscript.json`:
13
13
  ```json
14
14
  "executionApi": {
@@ -33,12 +33,13 @@ To use `clasp run`, you need to complete 5 steps:
33
33
  - If the `Project Number` is missing,
34
34
  - Click `Change Project`, paste the PROJECT_NUMBER, and click `Set project`
35
35
  1. Use your own OAuth 2 client. Create one by following these instructions:
36
- - `clasp open --creds`
36
+ - `clasp open-credentials-setup`
37
37
  - Press **Create credentials** > **OAuth client ID**
38
38
  - Application type: **Desktop App**
39
39
  - **Create** > **OK**
40
- - Download the file (⬇), move it to your directory, and name it `creds.json`. Please keep this file secret!
41
- 1. Call `clasp login --user <name> --creds creds.json`
40
+ - Download the file (⬇), move it to your directory, and name it `client_secret.json`. Please keep this file secret!
41
+ 1. Ensure that the [scopes required to run the script are listed in `appsscript.json`](https://developers.google.com/apps-script/concepts/scopes#set-explicit).
42
+ 1. Call `clasp login --user <name> --use-project-scopes --creds client_secret.json`
42
43
  1. Add the following to `appsscript.json`:
43
44
  ```json
44
45
  "executionApi": {
@@ -74,6 +75,6 @@ To run functions that use these scopes, you must add the scopes to your Apps Scr
74
75
 
75
76
  - `clasp open`
76
77
  - `File > Project Properties > Scopes`
77
- - Add these scopes to your `appsscript.json`.
78
- - Log in again: `clasp login --creds creds.json`. This will add these scopes to your credentials.
78
+ - Add these [scopes to your `appsscript.json`](https://developers.google.com/apps-script/concepts/scopes#set-explicit).
79
+ - Log in again: `clasp login --user <name> --use-project-scopes --creds creds.json`. This will add these scopes to your credentials.
79
80
  - `clasp run --user <name> sendMail`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google/clasp",
3
- "version": "3.0.1-alpha1",
3
+ "version": "3.0.3-alpha",
4
4
  "description": "Develop Apps Script Projects locally",
5
5
  "type": "module",
6
6
  "exports": "./build/src/index.js",
@@ -21,8 +21,8 @@
21
21
  "watch": "tspc --project tsconfig.json --watch",
22
22
  "prepare": "npm run compile",
23
23
  "lint": "npm run check",
24
- "test": "nyc mocha",
25
- "coverage": "nyc --cache false report --reporter=text-lcov | coveralls",
24
+ "test": "mocha",
25
+ "test:coverage": "c8 mocha",
26
26
  "prettier": "biome format src test --write",
27
27
  "check": "biome check src test && npm run compile",
28
28
  "clean": "rm -rf build",
@@ -39,21 +39,6 @@
39
39
  ],
40
40
  "outfile": "src/messages/messages.js"
41
41
  },
42
- "nyc": {
43
- "extends": "@istanbuljs/nyc-config-typescript",
44
- "include": [
45
- "src/**/*.ts"
46
- ],
47
- "extension": [
48
- ".ts"
49
- ],
50
- "reporter": [
51
- "text-summary",
52
- "html"
53
- ],
54
- "sourceMap": true,
55
- "instrument": true
56
- },
57
42
  "repository": {
58
43
  "type": "git",
59
44
  "url": "https://github.com/google/clasp"
@@ -130,10 +115,10 @@
130
115
  "@types/sinon": "^17.0.4",
131
116
  "@types/tmp": "^0.2.6",
132
117
  "@types/wtfnode": "^0.7.3",
118
+ "c8": "^10.1.3",
133
119
  "chai": "^5.1.2",
134
120
  "chai-as-promised": "^8.0.1",
135
121
  "chai-subset": "^1.6.0",
136
- "coveralls": "^3.1.1",
137
122
  "mocha": "^11.1.0",
138
123
  "mock-fs": "^5.4.1",
139
124
  "nock": "^14.0.0",