-
Notifications
You must be signed in to change notification settings - Fork 435
/
Copy pathfiles.ts
444 lines (380 loc) Β· 16 KB
/
files.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
import chalk from 'chalk';
import fs from 'fs-extra';
import makeDir from 'make-dir';
import multimatch from 'multimatch';
import path from 'path';
import pMap from 'p-map';
import recursive from 'recursive-readdir';
import typescript from 'typescript';
import {GaxiosError} from 'gaxios';
import {loadAPICredentials, script} from './auth.js';
import {ClaspError} from './clasp-error.js';
import {Conf} from './conf.js';
import {FS_OPTIONS, PROJECT_MANIFEST_FILENAME} from './constants.js';
import {DOTFILE} from './dotfile.js';
import {ERROR, LOG} from './messages.js';
import {getApiFileType, getErrorMessage, getProjectSettings, spinner, stopSpinner} from './utils.js';
import type {TranspileOptions} from 'typescript';
const {parseConfigFileTextToJson} = typescript;
const config = Conf.get();
// An Apps Script API File
interface AppsScriptFile {
readonly name: string;
readonly source: string;
readonly type: string;
}
interface ProjectFile {
readonly isIgnored: boolean;
readonly name: string;
readonly source: string;
readonly type: string;
}
async function transpile(source: string, transpileOptions: TranspileOptions): Promise<string> {
const ts2gas = await import('ts2gas');
return ts2gas.default(source, transpileOptions);
}
async function projectFileWithContent(file: ProjectFile, transpileOptions: TranspileOptions): Promise<ProjectFile> {
const content = await fs.readFile(file.name);
let source = content.toString();
let type = getApiFileType(file.name);
if (type === 'TS') {
source = await transpile(source, transpileOptions);
type = 'SERVER_JS';
}
return {...file, source, type};
}
const ignoredProjectFile = (file: ProjectFile): ProjectFile => ({...file, source: '', isIgnored: true, type: ''});
const isValidFactory = (rootDir: string) => {
const validManifestPath = rootDir ? path.join(rootDir, PROJECT_MANIFEST_FILENAME) : PROJECT_MANIFEST_FILENAME;
/**
* Validates a file:
*
* - is a manifest file
* - type is either `SERVER_JS` or `HTML` @see https://developers.google.com/apps-script/api/reference/rest/v1/File
*/
return (file: ProjectFile): boolean =>
Boolean(
file.type === 'JSON' // Has a type or is appsscript.json
? (rootDir ? path.normalize(file.name) : file.name) === validManifestPath
: file.type === 'SERVER_JS' || file.type === 'HTML'
);
};
/**
* Return an array of `ProjectFile` objects
*
* Recursively finds all files that are part of the current project, including those that are ignored by .claspignore
*
* > Note: content for each file is not returned. Use `getContentOfProjectFiles()` on the resulting array.
*
* @param rootDir the project's `rootDir`
*/
export const getAllProjectFiles = async (rootDir: string = path.join('.', '/')): Promise<ProjectFile[]> => {
try {
const ignorePatterns = await DOTFILE.IGNORE();
const isIgnored = (file: string) =>
multimatch(path.relative(rootDir, file), ignorePatterns, {dot: true}).length > 0;
const isValid = isValidFactory(rootDir);
// Read all filenames as a flattened tree
// Note: filePaths contain relative paths such as "test/bar.ts", "../../src/foo.js"
const files: ProjectFile[] = (await recursive(rootDir)).map((filename): ProjectFile => {
// Replace OS specific path separator to common '/' char for console output
const name = filename.replace(/\\/g, '/');
return {source: '', isIgnored: isIgnored(name), name, type: ''};
});
files.sort((a, b) => a.name.localeCompare(b.name));
const filesWithContent = await getContentOfProjectFiles(files);
return filesWithContent.map((file: ProjectFile): ProjectFile => {
// Loop through files that are not ignored from `.claspignore`
if (!file.isIgnored) {
// Prevent node_modules/@types/
if (file.name.includes('node_modules/@types')) {
return ignoredProjectFile(file);
}
// Check if there are files that will conflict if renamed .gs to .js.
// When pushing to Apps Script, these files will overwrite each other.
const parsed = path.parse(file.name);
if (parsed.ext === '.gs') {
const jsFile = `${parsed.dir}/${parsed.name}.js`;
// Can't rename, conflicting files
// Only print error once (for .gs)
if (files.findIndex(otherFile => !otherFile.isIgnored && otherFile.name === jsFile) !== -1) {
throw new ClaspError(ERROR.CONFLICTING_FILE_EXTENSION(`${parsed.dir}/${parsed.name}`));
}
}
return isValid(file) ? file : ignoredProjectFile(file);
}
return file;
});
} catch (error) {
if (error instanceof ClaspError) {
throw error;
}
// TODO improve error handling
throw error;
}
};
export const splitProjectFiles = (files: ProjectFile[]): [ProjectFile[], ProjectFile[]] => [
files.filter(file => !file.isIgnored),
files.filter(file => file.isIgnored),
];
async function getContentOfProjectFiles(files: ProjectFile[]) {
const transpileOptions = getTranspileOptions();
const getContent = (file: ProjectFile) => (file.isIgnored ? file : projectFileWithContent(file, transpileOptions));
return Promise.all(files.map(getContent));
}
async function getAppsScriptFilesFromProjectFiles(files: ProjectFile[], rootDir: string) {
const filesWithContent = await getContentOfProjectFiles(files);
return filesWithContent.map(file => {
const {name, source, type} = file;
return {
name: getAppsScriptFileName(rootDir, name), // The file base name
source, // The file contents
type, // The file extension
};
});
}
// This statement customizes the order in which the files are pushed.
// It puts the files in the setting's filePushOrder first.
// This is needed because Apps Script blindly executes files in order of creation time.
// The Apps Script API updates the creation time of files.
export const getOrderedProjectFiles = (files: ProjectFile[], filePushOrder: string[] | undefined) => {
const orderedFiles = [...files];
if (filePushOrder && filePushOrder.length > 0) {
// stopSpinner();
console.log('Detected filePushOrder setting. Pushing these files first:');
logFileList(filePushOrder);
console.log('');
orderedFiles.sort((a, b) => {
// Get the file order index
const indexA = filePushOrder.indexOf(a.name);
const indexB = filePushOrder.indexOf(b.name);
// If a file path isn't in the filePushOrder array, set the order to +β.
return (indexA > -1 ? indexA : Number.POSITIVE_INFINITY) - (indexB > -1 ? indexB : Number.POSITIVE_INFINITY);
});
}
return orderedFiles;
};
// // Used to receive files tracked by current project
// type FilesCallback = (error: Error | boolean, result: [string[], string[]], files: Array<AppsScriptFile>) => void;
/**
* Gets the local file type from the API FileType.
* @param {string} type The file type returned by Apps Script
* @return {string} The file type
* @see https://developers.google.com/apps-script/api/reference/rest/v1/File#FileType
*/
export const getLocalFileType = (type: string, fileExtension?: string): string =>
type === 'SERVER_JS' ? fileExtension ?? 'js' : type.toLowerCase();
/**
* Returns true if the user has a clasp project.
* @returns {boolean} If .clasp.json exists.
*/
export const hasProject = (): boolean => config.projectConfig !== undefined && fs.existsSync(config.projectConfig);
/**
* Returns in tsconfig.json.
* @returns {TranspileOptions} if tsconfig.json not exists, return an empty object.
*/
const getTranspileOptions = (): TranspileOptions => {
const tsconfigPath = path.join(config.projectRootDirectory!, 'tsconfig.json');
return fs.existsSync(tsconfigPath)
? {
compilerOptions: parseConfigFileTextToJson(tsconfigPath, fs.readFileSync(tsconfigPath, FS_OPTIONS)).config
.compilerOptions,
}
: {};
};
// /**
// * Recursively finds all files that are part of the current project, and those that are ignored
// * by .claspignore and calls the passed callback function with the file lists.
// * @param {string} rootDir The project's root directory
// * @param {FilesCallBack} callback The callback will be called with the following parameters
// * error: Error if there's an error, otherwise null
// * result: string[][], array of two lists of strings, ie. [validFilePaths,ignoredFilePaths]
// * files?: Array<AppsScriptFile> Array of AppsScriptFile objects used by clasp push
// * @todo Make this function actually return a Promise that can be awaited.
// */
// export const getProjectFiles = async (rootDir: string = path.join('.', '/'), callback: FilesCallback) => {
// try {
// const {filePushOrder} = await getProjectSettings();
// const allFiles = await getAllProjectFiles(rootDir);
// const [filesToPush, filesToIgnore] = splitProjectFiles(allFiles);
// const orderedFiles = getOrderedProjectFiles(filesToPush, filePushOrder);
// callback(
// false,
// [orderedFiles.map(file => file.name), filesToIgnore.map(file => file.name)],
// getAppsScriptFilesFromProjectFiles(orderedFiles, rootDir)
// );
// } catch (error) {
// return callback(error, [[], []], []);
// }
// };
/**
* @deprecated If the file is valid, add it to our file list.
* We generally want to allow for all file types, including files in node_modules/.
* However, node_modules/@types/ files should be ignored.
*/
export const isValidFileName = (
name: string,
type: string,
rootDir: string,
_normalizedName: string,
ignoreMatches: readonly string[]
): boolean => {
const isValid = isValidFactory(rootDir);
return Boolean(
!name.includes('node_modules/@types') && // Prevent node_modules/@types/
isValid({source: '', isIgnored: false, name, type}) &&
!ignoreMatches.includes(name) // Must be SERVER_JS or HTML. https://developers.google.com/apps-script/api/reference/rest/v1/File
);
};
/**
* Gets the name of the file for Apps Script.
* Formats rootDir/appsscript.json to appsscript.json.
* Preserves subdirectory names in rootDir
* (rootDir/foo/Code.js becomes foo/Code.js)
* @param {string} rootDir The directory to save the project files to.
* @param {string} filePath Path of file that is part of the current project
*/
export const getAppsScriptFileName = (rootDir: string, filePath: string) => {
const nameWithoutExt = filePath.slice(0, -path.extname(filePath).length);
// Replace OS specific path separator to common '/' char
return (rootDir ? path.relative(rootDir, nameWithoutExt) : nameWithoutExt).replace(/\\/g, '/');
};
/**
* Fetches the files for a project from the server
* @param {string} scriptId The project script id
* @param {number?} versionNumber The version of files to fetch.
* @returns {AppsScriptFile[]} Fetched files
*/
export const fetchProject = async (
scriptId: string,
versionNumber?: number,
silent = false
): Promise<AppsScriptFile[]> => {
await loadAPICredentials();
spinner.start();
let response;
try {
response = await script.projects.getContent({scriptId, versionNumber});
} catch (error) {
if (error instanceof ClaspError) {
throw error;
}
if ((error as any).statusCode === 404) {
throw new ClaspError(ERROR.SCRIPT_ID_INCORRECT(scriptId));
}
throw new ClaspError(ERROR.SCRIPT_ID);
}
stopSpinner();
const {files} = response.data;
if (!files) {
throw new ClaspError(ERROR.SCRIPT_ID_INCORRECT(scriptId));
}
if (!silent) {
console.log(LOG.CLONE_SUCCESS(files.length));
}
return files as AppsScriptFile[];
};
/**
* Writes files locally to `pwd` with dots converted to subdirectories.
* @param {AppsScriptFile[]} Files to write
* @param {string?} rootDir The directory to save the project files to. Defaults to `pwd`
*/
export const writeProjectFiles = async (files: AppsScriptFile[], rootDir = '') => {
try {
const {fileExtension} = await getProjectSettings();
const mapper = async (file: AppsScriptFile) => {
const filePath = `${file.name}.${getLocalFileType(file.type, fileExtension)}`;
const truePath = `${rootDir || '.'}/${filePath}`;
try {
await makeDir(path.dirname(truePath));
await fs.writeFile(truePath, file.source);
} catch (error: unknown) {
throw new ClaspError(getErrorMessage(error) ?? ERROR.FS_FILE_WRITE);
}
// Log only filename if pulling to root (Code.gs vs ./Code.gs)
console.log(`ββ ${rootDir ? truePath : filePath}`);
};
const fileList = files.filter(file => file.source); // Disallow empty files
fileList.sort((a, b) => a.name.localeCompare(b.name));
await pMap(fileList, mapper);
} catch (error) {
if (error instanceof ClaspError) {
throw error;
}
throw new ClaspError(getErrorMessage(error) ?? ERROR.FS_DIR_WRITE);
}
};
/**
* Pushes project files to script.google.com.
* @param {boolean} silent If true, doesn't console.log any success message.
*/
export const pushFiles = async (silent = false) => {
const {filePushOrder, scriptId, rootDir} = await getProjectSettings();
if (scriptId) {
const [toPush] = splitProjectFiles(await getAllProjectFiles(rootDir));
if (toPush.length > 0) {
const orderedFiles = getOrderedProjectFiles(toPush, filePushOrder);
const files = await getAppsScriptFilesFromProjectFiles(orderedFiles, rootDir ?? path.join('.', '/'));
const filenames = orderedFiles.map(file => file.name);
// Start pushing.
try {
await script.projects.updateContent({scriptId, requestBody: {scriptId, files}});
// No error
stopSpinner();
if (!silent) {
logFileList(filenames);
console.log(LOG.PUSH_SUCCESS(filenames.length));
}
} catch (error) {
stopSpinner();
console.error(LOG.PUSH_FAILURE);
if (error instanceof GaxiosError) {
let message = error.message;
let snippet = '';
const re = /Syntax error: (.+) line: (\d+) file: (.+)/;
const [, errorName, lineNum, fileName] = re.exec(error.message) ?? [];
if (fileName !== undefined) {
let filePath = path.resolve(rootDir ?? '.', fileName);
const parsedFilePath = path.parse(filePath);
// Check if the file exists locally as any supported type
const {fileExtension} = await getProjectSettings();
const extensions = ['gs', 'js', 'ts'];
if (fileExtension !== undefined) extensions.push(fileExtension);
for (const ext of extensions) {
const filePath_ext = path.join(parsedFilePath.dir, `${parsedFilePath.name}.${ext}`);
if (fs.existsSync(filePath_ext)) {
filePath = filePath_ext;
break;
}
}
message = `${errorName} - "${filePath}:${lineNum}"`;
// Get formatted code snippet
const contextCount = 4;
const parsedFileName = path.parse(fileName);
const fileNameKey = path.join(parsedFileName.dir, parsedFileName.name);
const reqFiles: ProjectFile[] = JSON.parse(error.config.body).files;
const errFile = reqFiles.find((x: ProjectFile) => x.name === fileNameKey && x.type === 'SERVER_JS');
if (errFile !== undefined) {
const srcLines = errFile.source.split('\n');
const errIndex = Math.max(parseInt(lineNum) - 1, 0);
const preIndex = Math.max(errIndex - contextCount, 0);
const postIndex = Math.min(errIndex + contextCount + 1, srcLines.length);
const preLines = chalk.dim(` ${srcLines.slice(preIndex, errIndex).join('\n ')}`);
const errLine = chalk.bold(`β ${srcLines[errIndex]}`);
const postLines = chalk.dim(` ${srcLines.slice(errIndex + 1, postIndex).join('\n ')}`);
snippet = preLines + '\n' + errLine + '\n' + postLines;
}
}
console.error(chalk.red(message));
console.log(snippet);
} else {
console.error(error);
}
}
} else {
stopSpinner();
console.log(LOG.PUSH_NO_FILES);
}
}
};
export const logFileList = (files: readonly string[]) => console.log(files.map(file => `ββ ${file}`).join('\n'));