@gadgetinc/ggt 0.3.2 → 0.4.0

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.
Files changed (136) hide show
  1. package/README.md +139 -76
  2. package/bin/dev.js +4 -7
  3. package/lib/__generated__/graphql.js.map +1 -1
  4. package/lib/commands/deploy.js +227 -0
  5. package/lib/commands/deploy.js.map +1 -0
  6. package/lib/commands/list.js +28 -21
  7. package/lib/commands/list.js.map +1 -1
  8. package/lib/commands/login.js +22 -20
  9. package/lib/commands/login.js.map +1 -1
  10. package/lib/commands/logout.js +13 -9
  11. package/lib/commands/logout.js.map +1 -1
  12. package/lib/commands/root.js +89 -56
  13. package/lib/commands/root.js.map +1 -1
  14. package/lib/commands/sync.js +256 -499
  15. package/lib/commands/sync.js.map +1 -1
  16. package/lib/commands/version.js +21 -0
  17. package/lib/commands/version.js.map +1 -0
  18. package/lib/commands/whoami.js +15 -11
  19. package/lib/commands/whoami.js.map +1 -1
  20. package/lib/main.js +4 -10
  21. package/lib/main.js.map +1 -1
  22. package/lib/services/{app.js → app/app.js} +9 -5
  23. package/lib/services/app/app.js.map +1 -0
  24. package/lib/services/app/arg.js +28 -0
  25. package/lib/services/app/arg.js.map +1 -0
  26. package/lib/services/app/edit-graphql.js +389 -0
  27. package/lib/services/app/edit-graphql.js.map +1 -0
  28. package/lib/services/command/arg.js +53 -0
  29. package/lib/services/command/arg.js.map +1 -0
  30. package/lib/services/command/command.js +27 -0
  31. package/lib/services/command/command.js.map +1 -0
  32. package/lib/services/command/context.js +60 -0
  33. package/lib/services/command/context.js.map +1 -0
  34. package/lib/services/{config.js → config/config.js} +32 -35
  35. package/lib/services/config/config.js.map +1 -0
  36. package/lib/services/config/env.js +22 -0
  37. package/lib/services/config/env.js.map +1 -0
  38. package/lib/services/config/package-json.js +9 -0
  39. package/lib/services/config/package-json.js.map +1 -0
  40. package/lib/services/filesync/changes.js +97 -0
  41. package/lib/services/filesync/changes.js.map +1 -0
  42. package/lib/services/filesync/conflicts.js +137 -0
  43. package/lib/services/filesync/conflicts.js.map +1 -0
  44. package/lib/services/filesync/directory.js +253 -0
  45. package/lib/services/filesync/directory.js.map +1 -0
  46. package/lib/services/filesync/error.js +67 -0
  47. package/lib/services/filesync/error.js.map +1 -0
  48. package/lib/services/filesync/file.js +3 -0
  49. package/lib/services/filesync/file.js.map +1 -0
  50. package/lib/services/filesync/filesync.js +675 -0
  51. package/lib/services/filesync/filesync.js.map +1 -0
  52. package/lib/services/filesync/hashes.js +150 -0
  53. package/lib/services/filesync/hashes.js.map +1 -0
  54. package/lib/services/http/auth.js +41 -0
  55. package/lib/services/http/auth.js.map +1 -0
  56. package/lib/services/http/http.js +64 -0
  57. package/lib/services/http/http.js.map +1 -0
  58. package/lib/services/output/log/field.js +3 -0
  59. package/lib/services/output/log/field.js.map +1 -0
  60. package/lib/services/output/log/format/format.js +8 -0
  61. package/lib/services/output/log/format/format.js.map +1 -0
  62. package/lib/services/output/log/format/json.js +45 -0
  63. package/lib/services/output/log/format/json.js.map +1 -0
  64. package/lib/services/output/log/format/pretty.js +147 -0
  65. package/lib/services/output/log/format/pretty.js.map +1 -0
  66. package/lib/services/output/log/level.js +41 -0
  67. package/lib/services/output/log/level.js.map +1 -0
  68. package/lib/services/output/log/logger.js +40 -0
  69. package/lib/services/output/log/logger.js.map +1 -0
  70. package/lib/services/output/log/printer.js +120 -0
  71. package/lib/services/output/log/printer.js.map +1 -0
  72. package/lib/services/output/log/structured.js +52 -0
  73. package/lib/services/output/log/structured.js.map +1 -0
  74. package/lib/services/{notify.js → output/notify.js} +7 -6
  75. package/lib/services/output/notify.js.map +1 -0
  76. package/lib/services/output/prompt.js +52 -0
  77. package/lib/services/output/prompt.js.map +1 -0
  78. package/lib/services/output/report.js +162 -0
  79. package/lib/services/output/report.js.map +1 -0
  80. package/lib/services/output/sprint.js +21 -0
  81. package/lib/services/output/sprint.js.map +1 -0
  82. package/lib/services/{output.js → output/stream.js} +18 -23
  83. package/lib/services/output/stream.js.map +1 -0
  84. package/lib/services/{version.js → output/update.js} +26 -18
  85. package/lib/services/output/update.js.map +1 -0
  86. package/lib/services/user/session.js +50 -0
  87. package/lib/services/user/session.js.map +1 -0
  88. package/lib/services/{user.js → user/user.js} +24 -17
  89. package/lib/services/user/user.js.map +1 -0
  90. package/lib/services/util/boolean.js +15 -0
  91. package/lib/services/util/boolean.js.map +1 -0
  92. package/lib/services/util/collection.js +38 -0
  93. package/lib/services/util/collection.js.map +1 -0
  94. package/lib/services/util/function.js +97 -0
  95. package/lib/services/util/function.js.map +1 -0
  96. package/lib/services/util/is.js +46 -0
  97. package/lib/services/util/is.js.map +1 -0
  98. package/lib/services/util/number.js +27 -0
  99. package/lib/services/util/number.js.map +1 -0
  100. package/lib/services/util/object.js +101 -0
  101. package/lib/services/util/object.js.map +1 -0
  102. package/lib/services/util/paths.js +36 -0
  103. package/lib/services/util/paths.js.map +1 -0
  104. package/lib/services/{promise.js → util/promise.js} +4 -4
  105. package/lib/services/util/promise.js.map +1 -0
  106. package/npm-shrinkwrap.json +2416 -1547
  107. package/package.json +52 -46
  108. package/lib/commands/index.js +0 -9
  109. package/lib/commands/index.js.map +0 -1
  110. package/lib/services/app.js.map +0 -1
  111. package/lib/services/args.js +0 -28
  112. package/lib/services/args.js.map +0 -1
  113. package/lib/services/config.js.map +0 -1
  114. package/lib/services/edit-graphql.js +0 -193
  115. package/lib/services/edit-graphql.js.map +0 -1
  116. package/lib/services/errors.js +0 -274
  117. package/lib/services/errors.js.map +0 -1
  118. package/lib/services/filesync.js +0 -404
  119. package/lib/services/filesync.js.map +0 -1
  120. package/lib/services/fs-utils.js +0 -33
  121. package/lib/services/fs-utils.js.map +0 -1
  122. package/lib/services/http.js +0 -53
  123. package/lib/services/http.js.map +0 -1
  124. package/lib/services/log.js +0 -45
  125. package/lib/services/log.js.map +0 -1
  126. package/lib/services/notify.js.map +0 -1
  127. package/lib/services/output.js.map +0 -1
  128. package/lib/services/promise.js.map +0 -1
  129. package/lib/services/session.js +0 -27
  130. package/lib/services/session.js.map +0 -1
  131. package/lib/services/sleep.js +0 -19
  132. package/lib/services/sleep.js.map +0 -1
  133. package/lib/services/timeout.js +0 -8
  134. package/lib/services/timeout.js.map +0 -1
  135. package/lib/services/user.js.map +0 -1
  136. package/lib/services/version.js.map +0 -1
@@ -0,0 +1,675 @@
1
+ import { _ as _define_property } from "@swc/helpers/_/_define_property";
2
+ import dayjs from "dayjs";
3
+ import { findUp } from "find-up";
4
+ import fs from "fs-extra";
5
+ import ms from "ms";
6
+ import assert from "node:assert";
7
+ import path from "node:path";
8
+ import process from "node:process";
9
+ import pMap from "p-map";
10
+ import PQueue from "p-queue";
11
+ import pRetry from "p-retry";
12
+ import { z } from "zod";
13
+ import { FileSyncEncoding } from "../../__generated__/graphql.js";
14
+ import { getApps } from "../app/app.js";
15
+ import { EditGraphQL, FILE_SYNC_COMPARISON_HASHES_QUERY, FILE_SYNC_FILES_QUERY, FILE_SYNC_HASHES_QUERY, PUBLISH_FILE_SYNC_EVENTS_MUTATION, REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION } from "../app/edit-graphql.js";
16
+ import { ArgError } from "../command/arg.js";
17
+ import { config, homePath } from "../config/config.js";
18
+ import { createLogger } from "../output/log/logger.js";
19
+ import { select } from "../output/prompt.js";
20
+ import { sprint } from "../output/sprint.js";
21
+ import { sortBySimilar } from "../util/collection.js";
22
+ import { noop } from "../util/function.js";
23
+ import { Changes, printChanges } from "./changes.js";
24
+ import { getConflicts, printConflicts, withoutConflictingChanges } from "./conflicts.js";
25
+ import { Directory, supportsPermissions, swallowEnoent } from "./directory.js";
26
+ import { InvalidSyncFileError, TooManySyncAttemptsError } from "./error.js";
27
+ import { getChanges, isEqualHashes } from "./hashes.js";
28
+ export class FileSync {
29
+ /**
30
+ * The last filesVersion that was written to the filesystem.
31
+ *
32
+ * This determines if the filesystem in Gadget is ahead of the
33
+ * filesystem on the local machine.
34
+ */ get filesVersion() {
35
+ return BigInt(this._state.filesVersion);
36
+ }
37
+ /**
38
+ * The largest mtime that was seen on the filesystem.
39
+ *
40
+ * This is used to determine if any files have changed since the last
41
+ * sync. This does not include the mtime of files that are ignored.
42
+ */ get mtime() {
43
+ return this._state.mtime;
44
+ }
45
+ /**
46
+ * Initializes a {@linkcode FileSync} instance.
47
+ * - Ensures the directory exists.
48
+ * - Ensures the directory is empty or contains a `.gadget/sync.json` file (unless `options.force` is `true`)
49
+ * - Ensures an app is specified (either via `options.app` or by prompting the user)
50
+ * - Ensures the specified app matches the app the directory was previously synced to (unless `options.force` is `true`)
51
+ */ static async init(options) {
52
+ const apps = await getApps(options.user);
53
+ if (apps.length === 0) {
54
+ throw new ArgError(sprint`
55
+ You (${options.user.email}) don't have have any Gadget applications.
56
+
57
+ Visit https://gadget.new to create one!
58
+ `);
59
+ }
60
+ let dir = options.dir;
61
+ if (!dir) {
62
+ // the user didn't specify a directory
63
+ const filepath = await findUp(".gadget/sync.json");
64
+ if (filepath) {
65
+ // we found a .gadget/sync.json file, use its parent directory
66
+ dir = path.join(filepath, "../..");
67
+ } else {
68
+ // we didn't find a .gadget/sync.json file, use the current directory
69
+ dir = process.cwd();
70
+ }
71
+ }
72
+ if (config.windows && dir.startsWith("~/")) {
73
+ // `~` doesn't expand to the home directory on Windows
74
+ dir = homePath(dir.slice(2));
75
+ }
76
+ // ensure the root directory is an absolute path and exists
77
+ const wasEmptyOrNonExistent = await isEmptyOrNonExistentDir(dir);
78
+ await fs.ensureDir(dir = path.resolve(dir));
79
+ // try to load the .gadget/sync.json file
80
+ const state = await fs.readJson(path.join(dir, ".gadget/sync.json")).then((json)=>z.object({
81
+ app: z.string(),
82
+ filesVersion: z.string(),
83
+ mtime: z.number()
84
+ }).parse(json)).catch(noop);
85
+ let appSlug = options.app || state?.app;
86
+ if (!appSlug) {
87
+ // the user didn't specify an app, suggest some apps that they can sync to
88
+ appSlug = await select({
89
+ message: "Select the app to sync to",
90
+ choices: apps.map((x)=>x.slug)
91
+ });
92
+ }
93
+ // try to find the appSlug in their list of apps
94
+ const app = apps.find((app)=>app.slug === appSlug);
95
+ if (!app) {
96
+ // the specified appSlug doesn't exist in their list of apps,
97
+ // either they misspelled it or they don't have access to it
98
+ // anymore, suggest some apps that are similar to the one they
99
+ // specified
100
+ const similarAppSlugs = sortBySimilar(appSlug, apps.map((app)=>app.slug)).slice(0, 5);
101
+ throw new ArgError(sprint`
102
+ Unknown application:
103
+
104
+ ${appSlug}
105
+
106
+ Did you mean one of these?
107
+
108
+
109
+ `.concat(` • ${similarAppSlugs.join("\n • ")}`));
110
+ }
111
+ const directory = await Directory.init(dir);
112
+ if (!state) {
113
+ // the .gadget/sync.json file didn't exist or contained invalid json
114
+ if (wasEmptyOrNonExistent || options.force) {
115
+ // the directory was empty or the user passed --force
116
+ // either way, create a fresh .gadget/sync.json file
117
+ return new FileSync(directory, wasEmptyOrNonExistent, app, {
118
+ app: app.slug,
119
+ filesVersion: "0",
120
+ mtime: 0
121
+ });
122
+ }
123
+ // the directory isn't empty and the user didn't pass --force
124
+ throw new InvalidSyncFileError(dir, app.slug);
125
+ }
126
+ // the .gadget/sync.json file exists
127
+ if (state.app === app.slug) {
128
+ // the .gadget/sync.json file is for the same app that the user specified
129
+ return new FileSync(directory, wasEmptyOrNonExistent, app, state);
130
+ }
131
+ // the .gadget/sync.json file is for a different app
132
+ if (options.force) {
133
+ // the user passed --force, so use the app they specified and overwrite everything
134
+ return new FileSync(directory, wasEmptyOrNonExistent, app, {
135
+ app: app.slug,
136
+ filesVersion: "0",
137
+ mtime: 0
138
+ });
139
+ }
140
+ // the user didn't pass --force, so throw an error
141
+ throw new ArgError(sprint`
142
+ You were about to sync the following app to the following directory:
143
+
144
+ {dim ${app.slug}} → {dim ${dir}}
145
+
146
+ However, that directory has already been synced with this app:
147
+
148
+ {dim ${state.app}}
149
+
150
+ If you're sure that you want to sync:
151
+
152
+ {dim ${app.slug}} → {dim ${dir}}
153
+
154
+ Then run {dim ggt sync} again with the {dim --force} flag.
155
+ `);
156
+ }
157
+ /**
158
+ * Waits for all pending and ongoing filesync operations to complete.
159
+ */ async idle() {
160
+ await this._queue.onIdle();
161
+ }
162
+ /**
163
+ * Sends file changes to the Gadget.
164
+ *
165
+ * @param changes - The changes to send.
166
+ * @returns A promise that resolves when the changes have been sent.
167
+ */ async sendChangesToGadget({ changes }) {
168
+ await this._enqueue(()=>this._sendChangesToGadget({
169
+ changes
170
+ }));
171
+ }
172
+ /**
173
+ * Subscribes to file changes on Gadget and executes the provided
174
+ * callbacks before and after the changes occur.
175
+ *
176
+ * @returns A function that unsubscribes from changes on Gadget.
177
+ */ subscribeToGadgetChanges({ beforeChanges, afterChanges, onError }) {
178
+ return this.editGraphQL.subscribe({
179
+ query: REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION,
180
+ // the reason this is a function rather than a static value is
181
+ // so that it will be re-evaluated if the connection is lost and
182
+ // then re-established. this ensures that we send our current
183
+ // filesVersion rather than the one that was sent when we first
184
+ // subscribed
185
+ variables: ()=>({
186
+ localFilesVersion: String(this.filesVersion)
187
+ }),
188
+ onError,
189
+ onData: ({ remoteFileSyncEvents: { changed, deleted, remoteFilesVersion } })=>{
190
+ this._enqueue(async ()=>{
191
+ if (BigInt(remoteFilesVersion) < this.filesVersion) {
192
+ this.log.warn("skipping received changes because files version is outdated", {
193
+ filesVersion: remoteFilesVersion
194
+ });
195
+ return;
196
+ }
197
+ this.log.debug("received files", {
198
+ remoteFilesVersion: remoteFilesVersion,
199
+ changed: changed.map((change)=>change.path),
200
+ deleted: deleted.map((change)=>change.path)
201
+ });
202
+ const filterIgnoredFiles = (file)=>{
203
+ const ignored = this.directory.ignores(file.path);
204
+ if (ignored) {
205
+ this.log.warn("skipping received change because file is ignored", {
206
+ path: file.path
207
+ });
208
+ }
209
+ return !ignored;
210
+ };
211
+ changed = changed.filter(filterIgnoredFiles);
212
+ deleted = deleted.filter(filterIgnoredFiles);
213
+ if (changed.length === 0 && deleted.length === 0) {
214
+ await this._save(remoteFilesVersion);
215
+ return;
216
+ }
217
+ await beforeChanges({
218
+ changed: changed.map((file)=>file.path),
219
+ deleted: deleted.map((file)=>file.path)
220
+ });
221
+ const changes = await this._writeToLocalFilesystem({
222
+ filesVersion: remoteFilesVersion,
223
+ files: changed,
224
+ delete: deleted.map((file)=>file.path)
225
+ });
226
+ if (changes.size > 0) {
227
+ printChanges({
228
+ message: sprint`← Received {gray ${dayjs().format("hh:mm:ss A")}}`,
229
+ changes,
230
+ tense: "past",
231
+ limit: 10
232
+ });
233
+ }
234
+ await afterChanges({
235
+ changes
236
+ });
237
+ }).catch(onError);
238
+ }
239
+ });
240
+ }
241
+ /**
242
+ * Ensures the local filesystem is in sync with Gadget's filesystem.
243
+ * - All non-conflicting changes are automatically merged.
244
+ * - Conflicts are resolved by prompting the user to either keep their
245
+ * local changes or keep Gadget's changes.
246
+ * - This function will not return until the filesystem is in sync.
247
+ */ async sync({ attempt = 0, preference } = {}) {
248
+ if (attempt > 10) {
249
+ throw new TooManySyncAttemptsError(attempt);
250
+ }
251
+ const { filesVersionHashes, localHashes, gadgetHashes, gadgetFilesVersion } = await this._getHashes();
252
+ this.log.debug("syncing", {
253
+ filesVersionHashes,
254
+ localHashes,
255
+ gadgetHashes,
256
+ gadgetFilesVersion
257
+ });
258
+ if (isEqualHashes(localHashes, gadgetHashes)) {
259
+ this.log.info("filesystem is in sync");
260
+ await this._save(gadgetFilesVersion);
261
+ return;
262
+ }
263
+ let localChanges = getChanges({
264
+ from: filesVersionHashes,
265
+ to: localHashes,
266
+ existing: gadgetHashes,
267
+ ignore: [
268
+ ".gadget/"
269
+ ]
270
+ });
271
+ let gadgetChanges = getChanges({
272
+ from: filesVersionHashes,
273
+ to: gadgetHashes,
274
+ existing: localHashes
275
+ });
276
+ if (localChanges.size === 0 && gadgetChanges.size === 0) {
277
+ // the local filesystem is missing .gadget/ files
278
+ gadgetChanges = getChanges({
279
+ from: localHashes,
280
+ to: gadgetHashes
281
+ });
282
+ assertAllGadgetFiles({
283
+ gadgetChanges
284
+ });
285
+ }
286
+ const conflicts = getConflicts({
287
+ localChanges,
288
+ gadgetChanges
289
+ });
290
+ if (conflicts.size > 0) {
291
+ this.log.debug("conflicts detected", {
292
+ conflicts
293
+ });
294
+ if (!preference) {
295
+ printConflicts({
296
+ message: sprint`{bold You have conflicting changes with Gadget}`,
297
+ conflicts
298
+ });
299
+ preference = await select({
300
+ message: "How would you like to resolve these conflicts?",
301
+ choices: Object.values(ConflictPreference)
302
+ });
303
+ }
304
+ switch(preference){
305
+ case ConflictPreference.CANCEL:
306
+ {
307
+ process.exit(0);
308
+ break;
309
+ }
310
+ case ConflictPreference.LOCAL:
311
+ {
312
+ gadgetChanges = withoutConflictingChanges({
313
+ conflicts,
314
+ changes: gadgetChanges
315
+ });
316
+ break;
317
+ }
318
+ case ConflictPreference.GADGET:
319
+ {
320
+ localChanges = withoutConflictingChanges({
321
+ conflicts,
322
+ changes: localChanges
323
+ });
324
+ break;
325
+ }
326
+ }
327
+ }
328
+ assert(localChanges.size > 0 || gadgetChanges.size > 0, "there must be changes if hashes don't match");
329
+ if (gadgetChanges.size > 0) {
330
+ await this._getChangesFromGadget({
331
+ changes: gadgetChanges,
332
+ filesVersion: gadgetFilesVersion
333
+ });
334
+ }
335
+ if (localChanges.size > 0) {
336
+ await this._sendChangesToGadget({
337
+ changes: localChanges,
338
+ expectedFilesVersion: gadgetFilesVersion
339
+ });
340
+ }
341
+ // recursively call this function until we're in sync
342
+ return this.sync({
343
+ attempt: ++attempt,
344
+ preference
345
+ });
346
+ }
347
+ async _getHashes() {
348
+ const [localHashes, { filesVersionHashes, gadgetHashes, gadgetFilesVersion }] = await Promise.all([
349
+ // get the hashes of our local files
350
+ this.directory.hashes(),
351
+ // get the hashes of our local filesVersion and the latest filesVersion
352
+ (async ()=>{
353
+ let gadgetFilesVersion;
354
+ let gadgetHashes;
355
+ let filesVersionHashes;
356
+ if (this.filesVersion === 0n) {
357
+ // this is the first time we're syncing, so just get the
358
+ // hashes of the latest filesVersion
359
+ const { fileSyncHashes } = await this.editGraphQL.query({
360
+ query: FILE_SYNC_HASHES_QUERY
361
+ });
362
+ gadgetFilesVersion = BigInt(fileSyncHashes.filesVersion);
363
+ gadgetHashes = fileSyncHashes.hashes;
364
+ filesVersionHashes = {};
365
+ } else {
366
+ // this isn't the first time we're syncing, so get the hashes
367
+ // of the files at our local filesVersion and the latest
368
+ // filesVersion
369
+ const { fileSyncComparisonHashes } = await this.editGraphQL.query({
370
+ query: FILE_SYNC_COMPARISON_HASHES_QUERY,
371
+ variables: {
372
+ filesVersion: String(this.filesVersion)
373
+ }
374
+ });
375
+ gadgetFilesVersion = BigInt(fileSyncComparisonHashes.latestFilesVersionHashes.filesVersion);
376
+ gadgetHashes = fileSyncComparisonHashes.latestFilesVersionHashes.hashes;
377
+ filesVersionHashes = fileSyncComparisonHashes.filesVersionHashes.hashes;
378
+ }
379
+ return {
380
+ filesVersionHashes,
381
+ gadgetHashes,
382
+ gadgetFilesVersion
383
+ };
384
+ })()
385
+ ]);
386
+ return {
387
+ filesVersionHashes,
388
+ localHashes,
389
+ gadgetHashes,
390
+ gadgetFilesVersion
391
+ };
392
+ }
393
+ async _getChangesFromGadget({ filesVersion, changes }) {
394
+ this.log.debug("getting changes from gadget", {
395
+ filesVersion,
396
+ changes
397
+ });
398
+ const created = changes.created();
399
+ const updated = changes.updated();
400
+ let files = [];
401
+ if (created.length > 0 || updated.length > 0) {
402
+ const { fileSyncFiles } = await this.editGraphQL.query({
403
+ query: FILE_SYNC_FILES_QUERY,
404
+ variables: {
405
+ paths: [
406
+ ...created,
407
+ ...updated
408
+ ],
409
+ filesVersion: String(filesVersion),
410
+ encoding: FileSyncEncoding.Base64
411
+ }
412
+ });
413
+ files = fileSyncFiles.files;
414
+ }
415
+ await this._writeToLocalFilesystem({
416
+ filesVersion,
417
+ files,
418
+ delete: changes.deleted()
419
+ });
420
+ printChanges({
421
+ changes,
422
+ tense: "past",
423
+ message: sprint`← Received {gray ${dayjs().format("hh:mm:ss A")}}`
424
+ });
425
+ }
426
+ async _sendChangesToGadget({ expectedFilesVersion = this.filesVersion, changes, printLimit }) {
427
+ this.log.debug("sending changes to gadget", {
428
+ expectedFilesVersion,
429
+ changes
430
+ });
431
+ const changed = [];
432
+ const deleted = [];
433
+ await pMap(changes, async ([normalizedPath, change])=>{
434
+ if (change.type === "delete") {
435
+ deleted.push({
436
+ path: normalizedPath
437
+ });
438
+ return;
439
+ }
440
+ const absolutePath = this.directory.absolute(normalizedPath);
441
+ let stats;
442
+ try {
443
+ stats = await fs.stat(absolutePath);
444
+ } catch (error) {
445
+ swallowEnoent(error);
446
+ this.log.debug("skipping change because file doesn't exist", {
447
+ path: normalizedPath
448
+ });
449
+ return;
450
+ }
451
+ let content = "";
452
+ if (stats.isFile()) {
453
+ content = await fs.readFile(absolutePath, FileSyncEncoding.Base64);
454
+ }
455
+ let oldPath;
456
+ if (change.type === "create" && change.oldPath) {
457
+ oldPath = change.oldPath;
458
+ }
459
+ changed.push({
460
+ content,
461
+ oldPath,
462
+ path: normalizedPath,
463
+ mode: stats.mode,
464
+ encoding: FileSyncEncoding.Base64
465
+ });
466
+ });
467
+ if (changed.length === 0 && deleted.length === 0) {
468
+ this.log.debug("skipping send because there are no changes");
469
+ return;
470
+ }
471
+ const { publishFileSyncEvents: { remoteFilesVersion } } = await this.editGraphQL.query({
472
+ query: PUBLISH_FILE_SYNC_EVENTS_MUTATION,
473
+ variables: {
474
+ input: {
475
+ expectedRemoteFilesVersion: String(expectedFilesVersion),
476
+ changed,
477
+ deleted
478
+ }
479
+ }
480
+ });
481
+ await this._save(remoteFilesVersion);
482
+ printChanges({
483
+ changes,
484
+ tense: "past",
485
+ message: sprint`→ Sent {gray ${dayjs().format("hh:mm:ss A")}}`,
486
+ limit: printLimit
487
+ });
488
+ }
489
+ async _writeToLocalFilesystem(options) {
490
+ const filesVersion = BigInt(options.filesVersion);
491
+ assert(filesVersion >= this.filesVersion, "filesVersion must be greater than or equal to current filesVersion");
492
+ this.log.debug("writing to local filesystem", {
493
+ filesVersion,
494
+ files: options.files.map((file)=>file.path),
495
+ delete: options.delete
496
+ });
497
+ const created = [];
498
+ const updated = [];
499
+ await pMap(options.delete, async (filepath)=>{
500
+ const currentPath = this.directory.absolute(filepath);
501
+ const backupPath = this.directory.absolute(".gadget/backup", this.directory.relative(filepath));
502
+ // rather than `rm -rf`ing files, we move them to
503
+ // `.gadget/backup/` so that users can recover them if something
504
+ // goes wrong. We've seen a lot of EBUSY/EINVAL errors when moving
505
+ // files so we retry a few times.
506
+ await pRetry(async ()=>{
507
+ try {
508
+ // remove the current backup file in case it exists and is a
509
+ // different type (file vs directory)
510
+ await fs.remove(backupPath);
511
+ await fs.move(currentPath, backupPath);
512
+ } catch (error) {
513
+ // replicate the behavior of `rm -rf` and ignore ENOENT
514
+ swallowEnoent(error);
515
+ }
516
+ }, {
517
+ retries: 2,
518
+ minTimeout: ms("100ms"),
519
+ onFailedAttempt: (error)=>{
520
+ this.log.warn("failed to move file to backup", {
521
+ error,
522
+ currentPath,
523
+ backupPath
524
+ });
525
+ }
526
+ });
527
+ });
528
+ await pMap(options.files, async (file)=>{
529
+ const absolutePath = this.directory.absolute(file.path);
530
+ if (await fs.pathExists(absolutePath)) {
531
+ updated.push(file.path);
532
+ } else {
533
+ created.push(file.path);
534
+ }
535
+ if (file.path.endsWith("/")) {
536
+ await fs.ensureDir(absolutePath);
537
+ } else {
538
+ await fs.outputFile(absolutePath, Buffer.from(file.content, file.encoding));
539
+ }
540
+ if (supportsPermissions) {
541
+ // the os's default umask makes setting the mode during creation
542
+ // not work, so an additional fs.chmod call is necessary to
543
+ // ensure the file has the correct mode
544
+ await fs.chmod(absolutePath, file.mode & 0o777);
545
+ }
546
+ if (absolutePath === this.directory.absolute(".ignore")) {
547
+ await this.directory.loadIgnoreFile();
548
+ }
549
+ });
550
+ await this._save(String(filesVersion));
551
+ return new Changes([
552
+ ...created.map((path)=>[
553
+ path,
554
+ {
555
+ type: "create"
556
+ }
557
+ ]),
558
+ ...updated.map((path)=>[
559
+ path,
560
+ {
561
+ type: "update"
562
+ }
563
+ ]),
564
+ ...options.delete.map((path)=>[
565
+ path,
566
+ {
567
+ type: "delete"
568
+ }
569
+ ])
570
+ ]);
571
+ }
572
+ /**
573
+ * Updates {@linkcode _state} and saves it to `.gadget/sync.json`.
574
+ */ async _save(filesVersion) {
575
+ this._state = {
576
+ ...this._state,
577
+ mtime: Date.now() + 1,
578
+ filesVersion: String(filesVersion)
579
+ };
580
+ this.log.debug("saving state", {
581
+ state: this._state
582
+ });
583
+ await fs.outputJSON(this.directory.absolute(".gadget/sync.json"), this._state, {
584
+ spaces: 2
585
+ });
586
+ }
587
+ /**
588
+ * Enqueues a function that handles filesync events onto the {@linkcode _queue}.
589
+ */ _enqueue(fn) {
590
+ return this._queue.add(fn);
591
+ }
592
+ constructor(/**
593
+ * The directory that is being synced to.
594
+ */ directory, /**
595
+ * Whether the directory was empty or non-existent when we started.
596
+ */ wasEmptyOrNonExistent, /**
597
+ * The Gadget application that is being synced to.
598
+ */ app, /**
599
+ * The state of the filesystem.
600
+ *
601
+ * This is persisted to `.gadget/sync.json` within the {@linkcode directory}.
602
+ */ _state){
603
+ _define_property(this, "directory", void 0);
604
+ _define_property(this, "wasEmptyOrNonExistent", void 0);
605
+ _define_property(this, "app", void 0);
606
+ _define_property(this, "_state", void 0);
607
+ _define_property(this, "editGraphQL", void 0);
608
+ _define_property(this, "log", void 0);
609
+ /**
610
+ * A FIFO async callback queue that ensures we process filesync events
611
+ * in the order we receive them.
612
+ */ _define_property(this, "_queue", void 0);
613
+ this.directory = directory;
614
+ this.wasEmptyOrNonExistent = wasEmptyOrNonExistent;
615
+ this.app = app;
616
+ this._state = _state;
617
+ this.log = createLogger({
618
+ name: "filesync",
619
+ fields: ()=>({
620
+ state: this._state
621
+ })
622
+ });
623
+ this._queue = new PQueue({
624
+ concurrency: 1
625
+ });
626
+ this.editGraphQL = new EditGraphQL(this.app);
627
+ }
628
+ }
629
+ /**
630
+ * Checks if a directory is empty or non-existent.
631
+ *
632
+ * @param dir - The directory path to check.
633
+ * @returns A Promise that resolves to a boolean indicating whether the directory is empty or non-existent.
634
+ */ export const isEmptyOrNonExistentDir = async (dir)=>{
635
+ try {
636
+ for await (const _ of (await fs.opendir(dir, {
637
+ bufferSize: 1
638
+ }))){
639
+ return false;
640
+ }
641
+ return true;
642
+ } catch (error) {
643
+ swallowEnoent(error);
644
+ return true;
645
+ }
646
+ };
647
+ export const assertAllGadgetFiles = ({ gadgetChanges })=>{
648
+ assert(gadgetChanges.created().length > 0, "expected gadgetChanges to have created files");
649
+ assert(gadgetChanges.deleted().length === 0, "expected gadgetChanges to not have deleted files");
650
+ assert(gadgetChanges.updated().length === 0, "expected gadgetChanges to not have updated files");
651
+ const allGadgetFiles = Array.from(gadgetChanges.keys()).every((path)=>path.startsWith(".gadget/"));
652
+ assert(allGadgetFiles, "expected all gadgetChanges to be .gadget/ files");
653
+ };
654
+ export const ConflictPreference = Object.freeze({
655
+ CANCEL: "Cancel (Ctrl+C)",
656
+ LOCAL: "Keep my conflicting changes",
657
+ GADGET: "Keep Gadget's conflicting changes"
658
+ });
659
+ export const ConflictPreferenceArg = (value, name)=>{
660
+ if ([
661
+ "local",
662
+ "gadget"
663
+ ].includes(value)) {
664
+ return ConflictPreference[value.toUpperCase()];
665
+ }
666
+ throw new ArgError(sprint`
667
+ ${name} must be {bold local} or {bold gadget}
668
+
669
+ {bold EXAMPLES:}
670
+ ${name} local
671
+ ${name} gadget
672
+ `);
673
+ };
674
+
675
+ //# sourceMappingURL=filesync.js.map