@gadgetinc/ggt 0.4.10 → 1.0.1

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 (142) hide show
  1. package/README.md +165 -93
  2. package/lib/__generated__/graphql.js +66 -1
  3. package/lib/__generated__/graphql.js.map +1 -1
  4. package/lib/commands/deploy.js +328 -230
  5. package/lib/commands/deploy.js.map +1 -1
  6. package/lib/commands/dev.js +445 -0
  7. package/lib/commands/dev.js.map +1 -0
  8. package/lib/commands/list.js +27 -19
  9. package/lib/commands/list.js.map +1 -1
  10. package/lib/commands/login.js +15 -11
  11. package/lib/commands/login.js.map +1 -1
  12. package/lib/commands/logout.js +5 -5
  13. package/lib/commands/logout.js.map +1 -1
  14. package/lib/commands/open.js +200 -0
  15. package/lib/commands/open.js.map +1 -0
  16. package/lib/commands/pull.js +128 -0
  17. package/lib/commands/pull.js.map +1 -0
  18. package/lib/commands/push.js +126 -0
  19. package/lib/commands/push.js.map +1 -0
  20. package/lib/commands/root.js +46 -28
  21. package/lib/commands/root.js.map +1 -1
  22. package/lib/commands/status.js +61 -0
  23. package/lib/commands/status.js.map +1 -0
  24. package/lib/commands/version.js +6 -6
  25. package/lib/commands/version.js.map +1 -1
  26. package/lib/commands/whoami.js +6 -6
  27. package/lib/commands/whoami.js.map +1 -1
  28. package/lib/ggt.js +33 -8
  29. package/lib/ggt.js.map +1 -1
  30. package/lib/main.js +5 -0
  31. package/lib/main.js.map +1 -0
  32. package/lib/services/app/api/api.js +191 -0
  33. package/lib/services/app/api/api.js.map +1 -0
  34. package/lib/services/app/api/operation.js +12 -0
  35. package/lib/services/app/api/operation.js.map +1 -0
  36. package/lib/services/app/app.js +44 -10
  37. package/lib/services/app/app.js.map +1 -1
  38. package/lib/services/app/{edit/client.js → client.js} +29 -19
  39. package/lib/services/app/client.js.map +1 -0
  40. package/lib/services/app/edit/edit.js +67 -31
  41. package/lib/services/app/edit/edit.js.map +1 -1
  42. package/lib/services/app/edit/operation.js +4 -3
  43. package/lib/services/app/edit/operation.js.map +1 -1
  44. package/lib/services/app/{edit/error.js → error.js} +6 -6
  45. package/lib/services/app/error.js.map +1 -0
  46. package/lib/services/command/arg.js +4 -4
  47. package/lib/services/command/arg.js.map +1 -1
  48. package/lib/services/command/command.js +9 -7
  49. package/lib/services/command/command.js.map +1 -1
  50. package/lib/services/command/context.js +82 -20
  51. package/lib/services/command/context.js.map +1 -1
  52. package/lib/services/config/config.js +4 -7
  53. package/lib/services/config/config.js.map +1 -1
  54. package/lib/services/config/env.js +1 -1
  55. package/lib/services/config/env.js.map +1 -1
  56. package/lib/services/filesync/changes.js +76 -37
  57. package/lib/services/filesync/changes.js.map +1 -1
  58. package/lib/services/filesync/conflicts.js +10 -9
  59. package/lib/services/filesync/conflicts.js.map +1 -1
  60. package/lib/services/filesync/directory.js +16 -1
  61. package/lib/services/filesync/directory.js.map +1 -1
  62. package/lib/services/filesync/error.js +96 -27
  63. package/lib/services/filesync/error.js.map +1 -1
  64. package/lib/services/filesync/filesync.js +516 -516
  65. package/lib/services/filesync/filesync.js.map +1 -1
  66. package/lib/services/filesync/hashes.js +8 -5
  67. package/lib/services/filesync/hashes.js.map +1 -1
  68. package/lib/services/filesync/strategy.js +59 -0
  69. package/lib/services/filesync/strategy.js.map +1 -0
  70. package/lib/services/filesync/sync-json.js +475 -0
  71. package/lib/services/filesync/sync-json.js.map +1 -0
  72. package/lib/services/http/auth.js +30 -1
  73. package/lib/services/http/auth.js.map +1 -1
  74. package/lib/services/http/http.js +5 -0
  75. package/lib/services/http/http.js.map +1 -1
  76. package/lib/services/output/confirm.js +149 -0
  77. package/lib/services/output/confirm.js.map +1 -0
  78. package/lib/services/output/footer.js +22 -0
  79. package/lib/services/output/footer.js.map +1 -0
  80. package/lib/services/output/log/format/pretty.js +2 -1
  81. package/lib/services/output/log/format/pretty.js.map +1 -1
  82. package/lib/services/output/log/logger.js +13 -5
  83. package/lib/services/output/log/logger.js.map +1 -1
  84. package/lib/services/output/log/structured.js +2 -2
  85. package/lib/services/output/log/structured.js.map +1 -1
  86. package/lib/services/output/output.js +197 -0
  87. package/lib/services/output/output.js.map +1 -0
  88. package/lib/services/output/print.js +31 -0
  89. package/lib/services/output/print.js.map +1 -0
  90. package/lib/services/output/problems.js +84 -0
  91. package/lib/services/output/problems.js.map +1 -0
  92. package/lib/services/output/prompt.js +173 -40
  93. package/lib/services/output/prompt.js.map +1 -1
  94. package/lib/services/output/report.js +63 -19
  95. package/lib/services/output/report.js.map +1 -1
  96. package/lib/services/output/select.js +198 -0
  97. package/lib/services/output/select.js.map +1 -0
  98. package/lib/services/output/spinner.js +141 -0
  99. package/lib/services/output/spinner.js.map +1 -0
  100. package/lib/services/output/sprint.js +38 -15
  101. package/lib/services/output/sprint.js.map +1 -1
  102. package/lib/services/output/symbols.js +23 -0
  103. package/lib/services/output/symbols.js.map +1 -0
  104. package/lib/services/output/table.js +98 -0
  105. package/lib/services/output/table.js.map +1 -0
  106. package/lib/services/output/timestamp.js +12 -0
  107. package/lib/services/output/timestamp.js.map +1 -0
  108. package/lib/services/output/update.js +29 -9
  109. package/lib/services/output/update.js.map +1 -1
  110. package/lib/services/user/session.js +4 -0
  111. package/lib/services/user/session.js.map +1 -1
  112. package/lib/services/user/user.js +15 -10
  113. package/lib/services/user/user.js.map +1 -1
  114. package/lib/services/util/assert.js +11 -0
  115. package/lib/services/util/assert.js.map +1 -0
  116. package/lib/services/util/boolean.js +2 -2
  117. package/lib/services/util/boolean.js.map +1 -1
  118. package/lib/services/util/function.js +45 -7
  119. package/lib/services/util/function.js.map +1 -1
  120. package/lib/services/util/is.js +35 -2
  121. package/lib/services/util/is.js.map +1 -1
  122. package/lib/services/util/json.js +16 -13
  123. package/lib/services/util/json.js.map +1 -1
  124. package/lib/services/util/object.js +2 -2
  125. package/lib/services/util/object.js.map +1 -1
  126. package/lib/services/util/promise.js +5 -2
  127. package/lib/services/util/promise.js.map +1 -1
  128. package/lib/services/util/types.js.map +1 -1
  129. package/npm-shrinkwrap.json +3425 -2983
  130. package/package.json +48 -41
  131. package/bin/dev.cmd +0 -3
  132. package/bin/dev.js +0 -14
  133. package/bin/run.cmd +0 -3
  134. package/bin/run.js +0 -5
  135. package/lib/commands/sync.js +0 -284
  136. package/lib/commands/sync.js.map +0 -1
  137. package/lib/services/app/edit/client.js.map +0 -1
  138. package/lib/services/app/edit/error.js.map +0 -1
  139. package/lib/services/output/log/printer.js +0 -120
  140. package/lib/services/output/log/printer.js.map +0 -1
  141. package/lib/services/output/stream.js +0 -54
  142. package/lib/services/output/stream.js.map +0 -1
@@ -1,7 +1,5 @@
1
1
  import { _ as _define_property } from "@swc/helpers/_/_define_property";
2
- import dayjs from "dayjs";
3
2
  import { execa } from "execa";
4
- import { findUp } from "find-up";
5
3
  import fs from "fs-extra";
6
4
  import ms from "ms";
7
5
  import assert from "node:assert";
@@ -10,159 +8,178 @@ import process from "node:process";
10
8
  import pMap from "p-map";
11
9
  import PQueue from "p-queue";
12
10
  import pRetry from "p-retry";
13
- import { z } from "zod";
11
+ import pluralize from "pluralize";
14
12
  import { FileSyncEncoding } from "../../__generated__/graphql.js";
15
- import { getApps } from "../app/app.js";
16
- import { AppArg } from "../app/arg.js";
17
- import { Edit } from "../app/edit/edit.js";
18
- import { EditError } from "../app/edit/error.js";
19
13
  import { 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/operation.js";
20
- import { ArgError } from "../command/arg.js";
21
- import { config, homePath } from "../config/config.js";
22
- import { select } from "../output/prompt.js";
23
- import { sprint } from "../output/sprint.js";
24
- import { getUserOrLogin } from "../user/user.js";
25
- import { sortBySimilar } from "../util/collection.js";
14
+ import { config } from "../config/config.js";
15
+ import { confirm } from "../output/confirm.js";
16
+ import { println } from "../output/print.js";
17
+ import { filesyncProblemsToProblems, sprintProblems } from "../output/problems.js";
18
+ import { EdgeCaseError } from "../output/report.js";
19
+ import { select } from "../output/select.js";
20
+ import { spin } from "../output/spinner.js";
21
+ import { sprint, sprintln } from "../output/sprint.js";
22
+ import { symbol } from "../output/symbols.js";
23
+ import { ts } from "../output/timestamp.js";
26
24
  import { noop } from "../util/function.js";
27
- import { isGraphQLErrors, isGraphQLResult, isObject, isString } from "../util/is.js";
28
- import { Changes, printChanges } from "./changes.js";
25
+ import { isEEXISTError, isENOENTError, isENOTDIRError, isENOTEMPTYError } from "../util/is.js";
26
+ import { serializeError } from "../util/object.js";
27
+ import { Changes, printChanges, sprintChanges } from "./changes.js";
29
28
  import { getConflicts, printConflicts, withoutConflictingChanges } from "./conflicts.js";
30
- import { Directory, supportsPermissions, swallowEnoent } from "./directory.js";
31
- import { InvalidSyncFileError, TooManySyncAttemptsError } from "./error.js";
32
- import { getChanges, isEqualHashes } from "./hashes.js";
29
+ import { supportsPermissions, swallowEnoent } from "./directory.js";
30
+ import { TooManyMergeAttemptsError, isFilesVersionMismatchError, swallowFilesVersionMismatch } from "./error.js";
31
+ import { getNecessaryChanges, isEqualHashes } from "./hashes.js";
32
+ import { MergeConflictPreference } from "./strategy.js";
33
+ /**
34
+ * The maximum attempts to automatically merge local and environment
35
+ * file changes when a FilesVersionMismatchError is encountered before
36
+ * throwing a {@linkcode TooManyMergeAttemptsError}.
37
+ */ export const MAX_MERGE_ATTEMPTS = 10;
38
+ /**
39
+ * The maximum length of file content that can be pushed to Gadget in a
40
+ * single request.
41
+ */ export const MAX_PUSH_CONTENT_LENGTH = 50 * 1024 * 1024; // 50mb
33
42
  export class FileSync {
34
- /**
35
- * The last filesVersion that was written to the filesystem.
36
- *
37
- * This determines if the filesystem in Gadget is ahead of the
38
- * filesystem on the local machine.
39
- */ get filesVersion() {
40
- return BigInt(this._syncJson.filesVersion);
41
- }
42
- /**
43
- * The largest mtime that was seen on the filesystem.
44
- *
45
- * This is used to determine if any files have changed since the last
46
- * sync. This does not include the mtime of files that are ignored.
47
- */ get mtime() {
48
- return this._syncJson.mtime;
49
- }
50
- /**
51
- * Initializes a {@linkcode FileSync} instance.
52
- * - Ensures the directory exists.
53
- * - Ensures the directory is empty or contains a `.gadget/sync.json` file (unless `options.force` is `true`)
54
- * - Ensures an app is specified (either via `options.app` or by prompting the user)
55
- * - Ensures the specified app matches the app the directory was previously synced to (unless `options.force` is `true`)
56
- */ static async init(ctx) {
57
- ctx = ctx.child({
58
- name: "filesync"
59
- });
60
- const user = await getUserOrLogin(ctx);
61
- const apps = await getApps(ctx);
62
- if (apps.length === 0) {
63
- throw new ArgError(sprint`
64
- You (${user.email}) don't have have any Gadget applications.
65
-
66
- Visit https://gadget.new to create one!
67
- `);
68
- }
69
- let dir = ctx.args._[0];
70
- if (!dir) {
71
- // the user didn't specify a directory
72
- const filepath = await findUp(".gadget/sync.json");
73
- if (filepath) {
74
- // we found a .gadget/sync.json file, use its parent directory
75
- dir = path.join(filepath, "../..");
43
+ async hashes(ctx) {
44
+ const spinner = spin({
45
+ ensureEmptyLineAbove: true
46
+ })`
47
+ Calculating file changes.
48
+ `;
49
+ try {
50
+ const [localHashes, { localFilesVersionHashes, environmentHashes, environmentFilesVersion }] = await Promise.all([
51
+ // get the hashes of our local files
52
+ this.syncJson.directory.hashes(),
53
+ // get the hashes of our local filesVersion and the latest filesVersion
54
+ (async ()=>{
55
+ let localFilesVersionHashes;
56
+ let environmentHashes;
57
+ let environmentFilesVersion;
58
+ if (this.syncJson.filesVersion === 0n) {
59
+ // we're either syncing for the first time or we're syncing a
60
+ // non-empty directory without a `.gadget/sync.json` file,
61
+ // regardless get the hashes of the latest filesVersion
62
+ const { fileSyncHashes } = await this.syncJson.edit.query({
63
+ query: FILE_SYNC_HASHES_QUERY
64
+ });
65
+ environmentFilesVersion = BigInt(fileSyncHashes.filesVersion);
66
+ environmentHashes = fileSyncHashes.hashes;
67
+ localFilesVersionHashes = {}; // represents an empty directory
68
+ } else {
69
+ // this isn't the first time we're syncing, so get the
70
+ // hashes of the files at our local filesVersion and the
71
+ // latest filesVersion
72
+ const { fileSyncComparisonHashes } = await this.syncJson.edit.query({
73
+ query: FILE_SYNC_COMPARISON_HASHES_QUERY,
74
+ variables: {
75
+ filesVersion: String(this.syncJson.filesVersion)
76
+ }
77
+ });
78
+ localFilesVersionHashes = fileSyncComparisonHashes.filesVersionHashes.hashes;
79
+ environmentHashes = fileSyncComparisonHashes.latestFilesVersionHashes.hashes;
80
+ environmentFilesVersion = BigInt(fileSyncComparisonHashes.latestFilesVersionHashes.filesVersion);
81
+ }
82
+ return {
83
+ localFilesVersionHashes,
84
+ environmentHashes,
85
+ environmentFilesVersion
86
+ };
87
+ })()
88
+ ]);
89
+ const inSync = isEqualHashes(ctx, localHashes, environmentHashes);
90
+ const localChanges = getNecessaryChanges(ctx, {
91
+ from: localFilesVersionHashes,
92
+ to: localHashes,
93
+ existing: environmentHashes,
94
+ ignore: [
95
+ ".gadget/"
96
+ ]
97
+ });
98
+ let environmentChanges = getNecessaryChanges(ctx, {
99
+ from: localFilesVersionHashes,
100
+ to: environmentHashes,
101
+ existing: localHashes
102
+ });
103
+ if (!inSync && localChanges.size === 0 && environmentChanges.size === 0) {
104
+ // we're not in sync, but neither the local filesystem nor the
105
+ // environment's filesystem have any changes; this is only
106
+ // possible if the local filesystem has modified .gadget/ files
107
+ environmentChanges = getNecessaryChanges(ctx, {
108
+ from: localHashes,
109
+ to: environmentHashes
110
+ });
111
+ assert(environmentChanges.size > 0, "expected environmentChanges to have changes");
112
+ assert(Array.from(environmentChanges.keys()).every((path)=>path.startsWith(".gadget/")), "expected all environmentChanges to be .gadget/ files");
113
+ }
114
+ assert(inSync || localChanges.size > 0 || environmentChanges.size > 0, "there must be changes if hashes don't match");
115
+ const localChangesToPush = getNecessaryChanges(ctx, {
116
+ from: environmentHashes,
117
+ to: localHashes,
118
+ ignore: [
119
+ ".gadget/"
120
+ ]
121
+ });
122
+ const environmentChangesToPull = getNecessaryChanges(ctx, {
123
+ from: localHashes,
124
+ to: environmentHashes
125
+ });
126
+ const onlyDotGadgetFilesChanged = Array.from(environmentChangesToPull.keys()).every((filepath)=>filepath.startsWith(".gadget/"));
127
+ const bothChanged = localChanges.size > 0 && environmentChanges.size > 0 && !onlyDotGadgetFilesChanged;
128
+ if (inSync) {
129
+ spinner.succeed`Your files are up to date. ${ts()}`;
76
130
  } else {
77
- // we didn't find a .gadget/sync.json file, use the current directory
78
- dir = process.cwd();
131
+ spinner.succeed`Calculated file changes. ${ts()}`;
79
132
  }
133
+ return {
134
+ inSync,
135
+ localFilesVersionHashes,
136
+ localHashes,
137
+ localChanges,
138
+ localChangesToPush,
139
+ environmentHashes,
140
+ environmentChanges,
141
+ environmentChangesToPull,
142
+ environmentFilesVersion,
143
+ onlyDotGadgetFilesChanged,
144
+ bothChanged
145
+ };
146
+ } catch (error) {
147
+ spinner.fail();
148
+ throw error;
80
149
  }
81
- if (config.windows && dir.startsWith("~/")) {
82
- // `~` doesn't expand to the home directory on Windows
83
- dir = homePath(dir.slice(2));
150
+ }
151
+ async print(ctx, { hashes } = {}) {
152
+ const { inSync, localChanges, environmentChanges, onlyDotGadgetFilesChanged, bothChanged } = hashes ?? await this.hashes(ctx);
153
+ if (inSync) {
154
+ // the spinner in hashes will have already printed that we're in sync
155
+ return;
84
156
  }
85
- // ensure the root directory is an absolute path and exists
86
- const wasEmptyOrNonExistent = await isEmptyOrNonExistentDir(dir);
87
- await fs.ensureDir(dir = path.resolve(dir));
88
- // try to load the .gadget/sync.json file
89
- const state = await fs.readJson(path.join(dir, ".gadget/sync.json")).then((json)=>z.object({
90
- app: z.string(),
91
- filesVersion: z.string(),
92
- mtime: z.number()
93
- }).parse(json)).catch(noop);
94
- let appSlug = ctx.args["--app"] || state?.app;
95
- if (!appSlug) {
96
- // the user didn't specify an app, suggest some apps that they can sync to
97
- appSlug = await select(ctx, {
98
- message: "Select the app to sync to",
99
- choices: apps.map((x)=>x.slug)
157
+ if (localChanges.size > 0) {
158
+ printChanges(ctx, {
159
+ changes: localChanges,
160
+ tense: "past",
161
+ title: sprint`Your local files {underline have} changed.`
100
162
  });
163
+ } else {
164
+ println({
165
+ ensureEmptyLineAbove: true
166
+ })`
167
+ Your local files {underline have not} changed.
168
+ `;
101
169
  }
102
- // try to find the appSlug in their list of apps
103
- const app = apps.find((app)=>app.slug === appSlug);
104
- if (!app) {
105
- // the specified appSlug doesn't exist in their list of apps,
106
- // either they misspelled it or they don't have access to it
107
- // anymore, suggest some apps that are similar to the one they
108
- // specified
109
- const similarAppSlugs = sortBySimilar(appSlug, apps.map((app)=>app.slug)).slice(0, 5);
110
- throw new ArgError(sprint`
111
- Unknown application:
112
-
113
- ${appSlug}
114
-
115
- Did you mean one of these?
116
-
117
-
118
- `.concat(` • ${similarAppSlugs.join("\n • ")}`));
119
- }
120
- ctx.app = app;
121
- const directory = await Directory.init(dir);
122
- if (!state) {
123
- // the .gadget/sync.json file didn't exist or contained invalid json
124
- if (wasEmptyOrNonExistent || ctx.args["--force"]) {
125
- // the directory was empty or the user passed --force
126
- // either way, create a fresh .gadget/sync.json file
127
- return new FileSync(ctx, directory, app, {
128
- app: app.slug,
129
- filesVersion: "0",
130
- mtime: 0
131
- });
132
- }
133
- // the directory isn't empty and the user didn't pass --force
134
- throw new InvalidSyncFileError(dir, app.slug);
135
- }
136
- // the .gadget/sync.json file exists
137
- if (state.app === app.slug) {
138
- // the .gadget/sync.json file is for the same app that the user specified
139
- return new FileSync(ctx, directory, app, state);
140
- }
141
- // the .gadget/sync.json file is for a different app
142
- if (ctx.args["--force"]) {
143
- // the user passed --force, so use the app they specified and overwrite everything
144
- return new FileSync(ctx, directory, app, {
145
- app: app.slug,
146
- filesVersion: "0",
147
- mtime: 0
170
+ if (environmentChanges.size > 0 && !onlyDotGadgetFilesChanged) {
171
+ printChanges(ctx, {
172
+ changes: environmentChanges,
173
+ tense: "past",
174
+ title: sprint`Your environment's files {underline have}${bothChanged ? " also" : ""} changed.`
148
175
  });
176
+ } else {
177
+ println({
178
+ ensureEmptyLineAbove: true
179
+ })`
180
+ Your environment's files {underline have not} changed.
181
+ `;
149
182
  }
150
- // the user didn't pass --force, so throw an error
151
- throw new ArgError(sprint`
152
- You were about to sync the following app to the following directory:
153
-
154
- {dim ${app.slug}} → {dim ${dir}}
155
-
156
- However, that directory has already been synced with this app:
157
-
158
- {dim ${state.app}}
159
-
160
- If you're sure that you want to sync:
161
-
162
- {dim ${app.slug}} → {dim ${dir}}
163
-
164
- Then run {dim ggt sync} again with the {dim --force} flag.
165
- `);
166
183
  }
167
184
  /**
168
185
  * Waits for all pending and ongoing filesync operations to complete.
@@ -170,23 +187,31 @@ export class FileSync {
170
187
  await this._syncOperations.onIdle();
171
188
  }
172
189
  /**
173
- * Sends file changes to the Gadget.
190
+ * Attempts to send file changes to the Gadget. If a files version
191
+ * mismatch error occurs, this function will merge the changes with
192
+ * Gadget instead.
174
193
  *
194
+ * @param ctx - The context to use.
175
195
  * @param options - The options to use.
176
196
  * @param options.changes - The changes to send.
197
+ * @param options.printLocalChangesOptions - The options to use when printing the local changes.
198
+ * @param options.printEnvironmentChangesOptions - The options to use when printing the changes from Gadget.
177
199
  * @returns A promise that resolves when the changes have been sent.
178
- */ async sendChangesToGadget({ changes }) {
200
+ */ async mergeChangesWithEnvironment(ctx, { changes, printLocalChangesOptions, printEnvironmentChangesOptions }) {
179
201
  await this._syncOperations.add(async ()=>{
180
202
  try {
181
- await this._sendChangesToGadget({
182
- changes
203
+ await this._sendChangesToEnvironment(ctx, {
204
+ changes,
205
+ printLocalChangesOptions
183
206
  });
184
207
  } catch (error) {
185
- swallowFilesVersionMismatch(this.ctx, error);
208
+ swallowFilesVersionMismatch(ctx, error);
186
209
  // we either sent the wrong expectedFilesVersion or we received
187
210
  // a filesVersion that is greater than the expectedFilesVersion
188
211
  // + 1, so we need to stop what we're doing and get in sync
189
- await this.sync();
212
+ await this.merge(ctx, {
213
+ printEnvironmentChangesOptions
214
+ });
190
215
  }
191
216
  });
192
217
  }
@@ -194,13 +219,15 @@ export class FileSync {
194
219
  * Subscribes to file changes on Gadget and executes the provided
195
220
  * callbacks before and after the changes occur.
196
221
  *
222
+ * @param ctx - The context to use.
197
223
  * @param options - The options to use.
198
224
  * @param options.beforeChanges - A callback that is called before the changes occur.
199
225
  * @param options.afterChanges - A callback that is called after the changes occur.
200
226
  * @param options.onError - A callback that is called if an error occurs.
227
+ * @param options.printEnvironmentChangesOptions - The options to use when printing the changes from Gadget.
201
228
  * @returns A function that unsubscribes from changes on Gadget.
202
- */ subscribeToGadgetChanges({ beforeChanges = noop, afterChanges = noop, onError }) {
203
- return this.edit.subscribe({
229
+ */ subscribeToEnvironmentChanges(ctx, { beforeChanges = noop, printEnvironmentChangesOptions, afterChanges = noop, onError }) {
230
+ return this.syncJson.edit.subscribe({
204
231
  subscription: REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION,
205
232
  // the reason this is a function rather than a static value is
206
233
  // so that it will be re-evaluated if the connection is lost and
@@ -208,26 +235,26 @@ export class FileSync {
208
235
  // filesVersion rather than the one that was sent when we first
209
236
  // subscribed
210
237
  variables: ()=>({
211
- localFilesVersion: String(this.filesVersion)
238
+ localFilesVersion: String(this.syncJson.filesVersion)
212
239
  }),
213
240
  onError,
214
241
  onData: ({ remoteFileSyncEvents: { changed, deleted, remoteFilesVersion } })=>{
215
242
  this._syncOperations.add(async ()=>{
216
- if (BigInt(remoteFilesVersion) < this.filesVersion) {
217
- this.ctx.log.warn("skipping received changes because files version is outdated", {
243
+ if (BigInt(remoteFilesVersion) < this.syncJson.filesVersion) {
244
+ ctx.log.warn("skipping received changes because files version is outdated", {
218
245
  filesVersion: remoteFilesVersion
219
246
  });
220
247
  return;
221
248
  }
222
- this.ctx.log.debug("received files", {
249
+ ctx.log.debug("received files", {
223
250
  remoteFilesVersion,
224
251
  changed: changed.map((change)=>change.path),
225
252
  deleted: deleted.map((change)=>change.path)
226
253
  });
227
254
  const filterIgnoredFiles = (file)=>{
228
- const ignored = this.directory.ignores(file.path);
255
+ const ignored = this.syncJson.directory.ignores(file.path);
229
256
  if (ignored) {
230
- this.ctx.log.warn("skipping received change because file is ignored", {
257
+ ctx.log.warn("skipping received change because file is ignored", {
231
258
  path: file.path
232
259
  });
233
260
  }
@@ -236,26 +263,25 @@ export class FileSync {
236
263
  changed = changed.filter(filterIgnoredFiles);
237
264
  deleted = deleted.filter(filterIgnoredFiles);
238
265
  if (changed.length === 0 && deleted.length === 0) {
239
- await this._save(remoteFilesVersion);
266
+ await this.syncJson.save(remoteFilesVersion);
240
267
  return;
241
268
  }
242
269
  await beforeChanges({
243
270
  changed: changed.map((file)=>file.path),
244
271
  deleted: deleted.map((file)=>file.path)
245
272
  });
246
- const changes = await this._writeToLocalFilesystem({
273
+ const changes = await this._writeToLocalFilesystem(ctx, {
247
274
  filesVersion: remoteFilesVersion,
248
275
  files: changed,
249
- delete: deleted.map((file)=>file.path)
250
- });
251
- if (changes.size > 0) {
252
- printChanges(this.ctx, {
253
- message: sprint`← Received {gray ${dayjs().format("hh:mm:ss A")}}`,
254
- changes,
276
+ delete: deleted.map((file)=>file.path),
277
+ printEnvironmentChangesOptions: {
255
278
  tense: "past",
256
- limit: 10
257
- });
258
- }
279
+ ensureEmptyLineAbove: true,
280
+ title: sprintln`{green ${symbol.tick}} Pulled ${pluralize("file", changed.length + deleted.length)}. ${symbol.arrowLeft} ${ts()}`,
281
+ limit: 5,
282
+ ...printEnvironmentChangesOptions
283
+ }
284
+ });
259
285
  await afterChanges({
260
286
  changes
261
287
  });
@@ -268,95 +294,131 @@ export class FileSync {
268
294
  * - All non-conflicting changes are automatically merged.
269
295
  * - Conflicts are resolved by prompting the user to either keep their local changes or keep Gadget's changes.
270
296
  * - This function will not return until the filesystem is in sync.
271
- */ async sync({ maxAttempts = 10 } = {}) {
297
+ */ async merge(ctx, { hashes, maxAttempts = 10, printLocalChangesOptions, printEnvironmentChangesOptions } = {}) {
272
298
  let attempt = 0;
273
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
274
- while(true){
275
- const { inSync, ...hashes } = await this.hashes();
276
- if (inSync) {
299
+ do {
300
+ if (attempt === 0) {
301
+ hashes ??= await this.hashes(ctx);
302
+ } else {
303
+ hashes = await this.hashes(ctx);
304
+ }
305
+ if (hashes.inSync) {
277
306
  this._syncOperations.clear();
278
- this.ctx.log.info("filesystem is in sync", {
279
- attempt
280
- });
281
- await this._save(hashes.gadgetFilesVersion);
307
+ ctx.log.info("filesystem in sync");
308
+ await this.syncJson.save(hashes.environmentFilesVersion);
282
309
  return;
283
310
  }
284
- if (attempt++ >= maxAttempts) {
285
- throw new TooManySyncAttemptsError(maxAttempts);
286
- }
311
+ attempt += 1;
312
+ ctx.log.info("merging", {
313
+ attempt,
314
+ ...hashes
315
+ });
287
316
  try {
288
- this.ctx.log.info("syncing", {
289
- attempt,
290
- ...hashes
317
+ await this._merge(ctx, {
318
+ hashes,
319
+ printLocalChangesOptions,
320
+ printEnvironmentChangesOptions
291
321
  });
292
- await this._sync(hashes);
293
322
  } catch (error) {
294
- swallowFilesVersionMismatch(this.ctx, error);
323
+ swallowFilesVersionMismatch(ctx, error);
295
324
  // we either sent the wrong expectedFilesVersion or we received
296
325
  // a filesVersion that is greater than the expectedFilesVersion
297
326
  // + 1, so try again
298
327
  }
299
- }
328
+ }while (attempt < maxAttempts)
329
+ throw new TooManyMergeAttemptsError(maxAttempts);
300
330
  }
301
- async _sync({ filesVersionHashes, localHashes, gadgetHashes, gadgetFilesVersion }) {
302
- let localChanges = getChanges(this.ctx, {
303
- from: filesVersionHashes,
304
- to: localHashes,
305
- existing: gadgetHashes,
306
- ignore: [
307
- ".gadget/"
308
- ]
309
- });
310
- let gadgetChanges = getChanges(this.ctx, {
311
- from: filesVersionHashes,
312
- to: gadgetHashes,
313
- existing: localHashes
314
- });
315
- if (localChanges.size === 0 && gadgetChanges.size === 0) {
316
- // the local filesystem is missing .gadget/ files
317
- gadgetChanges = getChanges(this.ctx, {
318
- from: localHashes,
319
- to: gadgetHashes
320
- });
321
- assertAllGadgetFiles({
322
- gadgetChanges
331
+ /**
332
+ * Pushes any changes made to the local filesystem since the last sync
333
+ * to Gadget.
334
+ *
335
+ * If Gadget has also made changes since the last sync, and --force
336
+ * was not passed, the user will be prompted to discard them.
337
+ */ async push(ctx, { hashes, force, printLocalChangesOptions } = {}) {
338
+ const { localChangesToPush, environmentChanges, environmentFilesVersion, onlyDotGadgetFilesChanged } = hashes ?? await this.hashes(ctx);
339
+ assert(localChangesToPush.size > 0, "cannot push if there are no changes");
340
+ // TODO: lift this check up to the push command
341
+ if (// they didn't pass --force
342
+ !(force ?? ctx.args["--force"]) && // their environment's files have changed
343
+ environmentChanges.size > 0 && // some of the changes aren't .gadget/ files
344
+ !onlyDotGadgetFilesChanged) {
345
+ await confirm({
346
+ ensureEmptyLineAbove: true
347
+ })`
348
+ Are you sure you want to {underline discard} your environment's changes?
349
+ `;
350
+ }
351
+ try {
352
+ await this._sendChangesToEnvironment(ctx, {
353
+ // what changes need to be made to your local files to make
354
+ // them match the environment's files
355
+ changes: localChangesToPush,
356
+ expectedFilesVersion: environmentFilesVersion,
357
+ printLocalChangesOptions
323
358
  });
359
+ } catch (error) {
360
+ swallowFilesVersionMismatch(ctx, error);
361
+ // we were told to push their local changes, but their
362
+ // environment's files have changed since we last checked, so
363
+ // throw a nicer error message
364
+ // TODO: we don't have to do this if only .gadget/ files changed
365
+ throw new EdgeCaseError(sprint`
366
+ Your environment's files have changed since we last checked.
367
+
368
+ Please re-run "ggt ${ctx.command}" to see the changes and try again.
369
+ `);
324
370
  }
325
- assert(localChanges.size > 0 || gadgetChanges.size > 0, "there must be changes if hashes don't match");
371
+ }
372
+ async pull(ctx, { hashes, force, printEnvironmentChangesOptions } = {}) {
373
+ const { localChanges, environmentChangesToPull, environmentFilesVersion } = hashes ?? await this.hashes(ctx);
374
+ assert(environmentChangesToPull.size > 0, "cannot push if there are no changes");
375
+ // TODO: lift this check up to the pull command
376
+ if (localChanges.size > 0 && !(force ?? ctx.args["--force"])) {
377
+ await confirm`
378
+ Are you sure you want to {underline discard} your local changes?
379
+ `;
380
+ }
381
+ await this._getChangesFromEnvironment(ctx, {
382
+ changes: environmentChangesToPull,
383
+ filesVersion: environmentFilesVersion,
384
+ printEnvironmentChangesOptions
385
+ });
386
+ }
387
+ async _merge(ctx, { hashes: { localChanges, environmentChanges, environmentFilesVersion }, printLocalChangesOptions, printEnvironmentChangesOptions }) {
326
388
  const conflicts = getConflicts({
327
389
  localChanges,
328
- gadgetChanges
390
+ environmentChanges
329
391
  });
330
392
  if (conflicts.size > 0) {
331
- this.ctx.log.debug("conflicts detected", {
393
+ ctx.log.debug("conflicts detected", {
332
394
  conflicts
333
395
  });
334
- let preference = this.ctx.args["--prefer"];
396
+ let preference = ctx.args["--prefer"];
335
397
  if (!preference) {
336
- printConflicts(this.ctx, {
337
- message: sprint`{bold You have conflicting changes with Gadget}`,
398
+ printConflicts({
338
399
  conflicts
339
400
  });
340
- preference = await select(this.ctx, {
341
- message: "How would you like to resolve these conflicts?",
342
- choices: Object.values(ConflictPreference)
343
- });
401
+ preference = await select({
402
+ choices: Object.values(MergeConflictPreference)
403
+ })`
404
+ {bold How should we resolve these conflicts?}
405
+ `;
344
406
  }
345
407
  switch(preference){
346
- case ConflictPreference.CANCEL:
408
+ case MergeConflictPreference.CANCEL:
347
409
  {
348
410
  process.exit(0);
349
411
  break;
350
412
  }
351
- case ConflictPreference.LOCAL:
413
+ case MergeConflictPreference.LOCAL:
352
414
  {
353
- gadgetChanges = withoutConflictingChanges({
415
+ environmentChanges = withoutConflictingChanges({
354
416
  conflicts,
355
- changes: gadgetChanges
417
+ changes: environmentChanges
356
418
  });
357
419
  break;
358
420
  }
359
- case ConflictPreference.GADGET:
421
+ case MergeConflictPreference.ENVIRONMENT:
360
422
  {
361
423
  localChanges = withoutConflictingChanges({
362
424
  conflicts,
@@ -366,101 +428,66 @@ export class FileSync {
366
428
  }
367
429
  }
368
430
  }
369
- if (gadgetChanges.size > 0) {
370
- await this._getChangesFromGadget({
371
- changes: gadgetChanges,
372
- filesVersion: gadgetFilesVersion
431
+ if (environmentChanges.size > 0) {
432
+ await this._getChangesFromEnvironment(ctx, {
433
+ changes: environmentChanges,
434
+ filesVersion: environmentFilesVersion,
435
+ printEnvironmentChangesOptions
373
436
  });
374
437
  }
375
438
  if (localChanges.size > 0) {
376
- await this._sendChangesToGadget({
439
+ await this._sendChangesToEnvironment(ctx, {
377
440
  changes: localChanges,
378
- expectedFilesVersion: gadgetFilesVersion
441
+ expectedFilesVersion: environmentFilesVersion,
442
+ printLocalChangesOptions
379
443
  });
380
444
  }
381
445
  }
382
- async hashes() {
383
- const [localHashes, { filesVersionHashes, gadgetHashes, gadgetFilesVersion }] = await Promise.all([
384
- // get the hashes of our local files
385
- this.directory.hashes(),
386
- // get the hashes of our local filesVersion and the latest filesVersion
387
- (async ()=>{
388
- let gadgetFilesVersion;
389
- let gadgetHashes;
390
- let filesVersionHashes;
391
- if (this.filesVersion === 0n) {
392
- // this is the first time we're syncing, so just get the
393
- // hashes of the latest filesVersion
394
- const { fileSyncHashes } = await this.edit.query({
395
- query: FILE_SYNC_HASHES_QUERY
396
- });
397
- gadgetFilesVersion = BigInt(fileSyncHashes.filesVersion);
398
- gadgetHashes = fileSyncHashes.hashes;
399
- filesVersionHashes = {};
400
- } else {
401
- // this isn't the first time we're syncing, so get the hashes
402
- // of the files at our local filesVersion and the latest
403
- // filesVersion
404
- const { fileSyncComparisonHashes } = await this.edit.query({
405
- query: FILE_SYNC_COMPARISON_HASHES_QUERY,
406
- variables: {
407
- filesVersion: String(this.filesVersion)
408
- }
409
- });
410
- gadgetFilesVersion = BigInt(fileSyncComparisonHashes.latestFilesVersionHashes.filesVersion);
411
- gadgetHashes = fileSyncComparisonHashes.latestFilesVersionHashes.hashes;
412
- filesVersionHashes = fileSyncComparisonHashes.filesVersionHashes.hashes;
413
- }
414
- return {
415
- filesVersionHashes,
416
- gadgetHashes,
417
- gadgetFilesVersion
418
- };
419
- })()
420
- ]);
421
- return {
422
- filesVersionHashes,
423
- localHashes,
424
- gadgetHashes,
425
- gadgetFilesVersion,
426
- inSync: isEqualHashes(this.ctx, localHashes, gadgetHashes)
427
- };
428
- }
429
- async _getChangesFromGadget({ filesVersion, changes }) {
430
- this.ctx.log.debug("getting changes from gadget", {
446
+ async _getChangesFromEnvironment(ctx, { filesVersion, changes, printEnvironmentChangesOptions }) {
447
+ ctx.log.debug("getting changes from gadget", {
431
448
  filesVersion,
432
449
  changes
433
450
  });
434
451
  const created = changes.created();
435
452
  const updated = changes.updated();
436
- let files = [];
437
- if (created.length > 0 || updated.length > 0) {
438
- const { fileSyncFiles } = await this.edit.query({
439
- query: FILE_SYNC_FILES_QUERY,
440
- variables: {
441
- paths: [
442
- ...created,
443
- ...updated
444
- ],
445
- filesVersion: String(filesVersion),
446
- encoding: FileSyncEncoding.Base64
447
- }
453
+ const spinner = spin({
454
+ ensureEmptyLineAbove: true
455
+ })(sprintChanges(ctx, {
456
+ changes,
457
+ tense: "present",
458
+ title: sprint`Pulling ${pluralize("file", changes.size)}. ${symbol.arrowLeft}`,
459
+ ...printEnvironmentChangesOptions
460
+ }));
461
+ try {
462
+ let files = [];
463
+ if (created.length > 0 || updated.length > 0) {
464
+ const { fileSyncFiles } = await this.syncJson.edit.query({
465
+ query: FILE_SYNC_FILES_QUERY,
466
+ variables: {
467
+ paths: [
468
+ ...created,
469
+ ...updated
470
+ ],
471
+ filesVersion: String(filesVersion),
472
+ encoding: FileSyncEncoding.Base64
473
+ }
474
+ });
475
+ files = fileSyncFiles.files;
476
+ }
477
+ await this._writeToLocalFilesystem(ctx, {
478
+ filesVersion,
479
+ files,
480
+ delete: changes.deleted(),
481
+ spinner,
482
+ printEnvironmentChangesOptions
448
483
  });
449
- files = fileSyncFiles.files;
484
+ } catch (error) {
485
+ spinner.fail();
486
+ throw error;
450
487
  }
451
- await this._writeToLocalFilesystem({
452
- filesVersion,
453
- files,
454
- delete: changes.deleted()
455
- });
456
- printChanges(this.ctx, {
457
- changes,
458
- tense: "past",
459
- message: sprint`← Received {gray ${dayjs().format("hh:mm:ss A")}}`
460
- });
461
488
  }
462
- async _sendChangesToGadget({ expectedFilesVersion = this.filesVersion, changes, printLimit }) {
463
- this.ctx.log.debug("sending changes to gadget", {
489
+ async _sendChangesToEnvironment(ctx, { changes, expectedFilesVersion = this.syncJson.filesVersion, printLocalChangesOptions }) {
490
+ ctx.log.debug("sending changes to gadget", {
464
491
  expectedFilesVersion,
465
492
  changes
466
493
  });
@@ -473,13 +500,13 @@ export class FileSync {
473
500
  });
474
501
  return;
475
502
  }
476
- const absolutePath = this.directory.absolute(normalizedPath);
503
+ const absolutePath = this.syncJson.directory.absolute(normalizedPath);
477
504
  let stats;
478
505
  try {
479
506
  stats = await fs.stat(absolutePath);
480
507
  } catch (error) {
481
508
  swallowEnoent(error);
482
- this.ctx.log.debug("skipping change because file doesn't exist", {
509
+ ctx.log.debug("skipping change because file doesn't exist", {
483
510
  path: normalizedPath
484
511
  });
485
512
  return;
@@ -501,79 +528,109 @@ export class FileSync {
501
528
  });
502
529
  });
503
530
  if (changed.length === 0 && deleted.length === 0) {
504
- this.ctx.log.debug("skipping send because there are no changes");
531
+ ctx.log.debug("skipping send because there are no changes");
505
532
  return;
506
533
  }
507
- const { publishFileSyncEvents: { remoteFilesVersion, problems } } = await this.edit.mutate({
508
- mutation: PUBLISH_FILE_SYNC_EVENTS_MUTATION,
509
- variables: {
510
- input: {
511
- expectedRemoteFilesVersion: String(expectedFilesVersion),
512
- changed,
513
- deleted
514
- }
515
- },
516
- http: {
517
- retry: {
518
- // we can retry this request because
519
- // expectedRemoteFilesVersion makes it idempotent
520
- methods: [
521
- "POST"
522
- ],
523
- calculateDelay: ({ error, computedValue })=>{
524
- if (isFilesVersionMismatchError(error.response?.body)) {
525
- // don't retry if we get a files version mismatch error
526
- return 0;
534
+ const contentLength = changed.map((change)=>change.content.length).reduce((a, b)=>a + b, 0);
535
+ if (contentLength > MAX_PUSH_CONTENT_LENGTH) {
536
+ throw new EdgeCaseError(sprint`
537
+ {underline Your file changes are too large to push.}
538
+
539
+ Run "ggt status" to see your changes and consider
540
+ ignoring some files or pushing in smaller batches.
541
+ `);
542
+ }
543
+ const spinner = spin({
544
+ ensureEmptyLineAbove: true
545
+ })(sprintChanges(ctx, {
546
+ changes,
547
+ tense: "present",
548
+ title: sprintln`Pushing ${pluralize("file", changed.length + deleted.length)}. ${symbol.arrowRight}`,
549
+ ...printLocalChangesOptions
550
+ }));
551
+ try {
552
+ const { publishFileSyncEvents: { remoteFilesVersion, problems: filesyncProblems } } = await this.syncJson.edit.mutate({
553
+ mutation: PUBLISH_FILE_SYNC_EVENTS_MUTATION,
554
+ variables: {
555
+ input: {
556
+ expectedRemoteFilesVersion: String(expectedFilesVersion),
557
+ changed,
558
+ deleted
559
+ }
560
+ },
561
+ http: {
562
+ retry: {
563
+ // we can retry this request because
564
+ // expectedRemoteFilesVersion makes it idempotent
565
+ methods: [
566
+ "POST"
567
+ ],
568
+ calculateDelay: ({ error, computedValue })=>{
569
+ if (isFilesVersionMismatchError(error.response?.body)) {
570
+ // don't retry if we get a files version mismatch error
571
+ return 0;
572
+ }
573
+ return computedValue;
527
574
  }
528
- return computedValue;
529
575
  }
530
576
  }
531
- }
532
- });
533
- printChanges(this.ctx, {
534
- changes,
535
- tense: "past",
536
- message: sprint`→ Sent {gray ${dayjs().format("hh:mm:ss A")}}`,
537
- limit: printLimit
538
- });
539
- if (BigInt(remoteFilesVersion) > expectedFilesVersion + 1n) {
540
- // we can't save the remoteFilesVersion because we haven't
541
- // received the intermediate filesVersions yet
542
- throw new Error("Files version mismatch");
543
- }
544
- if (problems.length > 0) {
545
- const problemGroup = {};
546
- problems.forEach((problem)=>{
547
- if (!(problem.path in problemGroup)) {
548
- problemGroup[problem.path] = [];
549
- }
550
- problemGroup[problem.path]?.push(problem.message);
551
577
  });
552
- this.ctx.log.println2`{red Gadget has detected the following fatal errors with your files:}`;
553
- Object.entries(problemGroup).forEach(([path, messages])=>{
554
- this.ctx.log.println`{red [${path}]}`;
555
- messages.forEach((message)=>{
556
- this.ctx.log.println`{red - ${message}}`;
557
- });
558
- });
559
- this.ctx.log.println("");
560
- this.ctx.log.println2`{red Your app will not be operational until all fatal errors are fixed.}`;
578
+ if (BigInt(remoteFilesVersion) > expectedFilesVersion + 1n) {
579
+ // we can't save the remoteFilesVersion because we haven't
580
+ // received the intermediate filesVersions yet
581
+ throw new Error("Files version mismatch");
582
+ }
583
+ await this.syncJson.save(remoteFilesVersion);
584
+ spinner.succeed(sprintChanges(ctx, {
585
+ changes,
586
+ tense: "past",
587
+ title: sprintln`Pushed ${pluralize("file", changed.length + deleted.length)}. ${symbol.arrowRight} ${ts()}`,
588
+ ...printLocalChangesOptions
589
+ }));
590
+ if (filesyncProblems.length > 0) {
591
+ println({
592
+ ensureEmptyLineAbove: true
593
+ })`
594
+ {red Gadget has detected the following fatal errors with your files:}
595
+
596
+ ${sprintProblems({
597
+ problems: filesyncProblemsToProblems(filesyncProblems),
598
+ showFileTypes: false,
599
+ indent: 10
600
+ })}
601
+
602
+ {red Your app will not be operational until all fatal errors are fixed.}
603
+ `;
604
+ }
605
+ } catch (error) {
606
+ if (isFilesVersionMismatchError(error)) {
607
+ spinner.clear();
608
+ } else {
609
+ spinner.fail();
610
+ }
611
+ throw error;
561
612
  }
562
- await this._save(remoteFilesVersion);
563
613
  }
564
- async _writeToLocalFilesystem(options) {
614
+ async _writeToLocalFilesystem(ctx, options) {
565
615
  const filesVersion = BigInt(options.filesVersion);
566
- assert(filesVersion >= this.filesVersion, "filesVersion must be greater than or equal to current filesVersion");
567
- this.ctx.log.debug("writing to local filesystem", {
616
+ assert(filesVersion >= this.syncJson.filesVersion, "filesVersion must be greater than or equal to current filesVersion");
617
+ ctx.log.debug("writing to local filesystem", {
568
618
  filesVersion,
569
619
  files: options.files.map((file)=>file.path),
570
620
  delete: options.delete
571
621
  });
572
- const created = [];
573
- const updated = [];
574
- await pMap(options.delete, async (filepath)=>{
575
- const currentPath = this.directory.absolute(filepath);
576
- const backupPath = this.directory.absolute(".gadget/backup", this.directory.relative(filepath));
622
+ const changes = new Changes();
623
+ const directoriesWithDeletedFiles = new Set();
624
+ await pMap(options.delete, async (pathToDelete)=>{
625
+ // add all the directories that contain this file to
626
+ // directoriesWithDeletedFiles so we can clean them up later
627
+ let dir = path.dirname(pathToDelete);
628
+ while(dir !== "."){
629
+ directoriesWithDeletedFiles.add(this.syncJson.directory.normalize(dir, true));
630
+ dir = path.dirname(dir);
631
+ }
632
+ const currentPath = this.syncJson.directory.absolute(pathToDelete);
633
+ const backupPath = this.syncJson.directory.absolute(".gadget/backup", this.syncJson.directory.relative(pathToDelete));
577
634
  // rather than `rm -rf`ing files, we move them to
578
635
  // `.gadget/backup/` so that users can recover them if something
579
636
  // goes wrong. We've seen a lot of EBUSY/EINVAL errors when moving
@@ -584,9 +641,36 @@ export class FileSync {
584
641
  // different type (file vs directory)
585
642
  await fs.remove(backupPath);
586
643
  await fs.move(currentPath, backupPath);
644
+ changes.set(pathToDelete, {
645
+ type: "delete"
646
+ });
587
647
  } catch (error) {
588
- // replicate the behavior of `rm -rf` and ignore ENOENT
589
- swallowEnoent(error);
648
+ if (isENOENTError(error)) {
649
+ // replicate the behavior of `rm -rf` and ignore ENOENT
650
+ return;
651
+ }
652
+ if (isENOTDIRError(error) || isEEXISTError(error)) {
653
+ // the backup path already exists and ends in a file
654
+ // rather than a directory, so we have to remove the file
655
+ // before we can move the current path to the backup path
656
+ let dir = path.dirname(backupPath);
657
+ while(dir !== this.syncJson.directory.absolute(".gadget/backup")){
658
+ const stats = await fs.stat(dir);
659
+ // eslint-disable-next-line max-depth
660
+ if (!stats.isDirectory()) {
661
+ // this file is in the way, so remove it
662
+ ctx.log.debug("removing file in the way of backup path", {
663
+ currentPath,
664
+ backupPath,
665
+ file: dir
666
+ });
667
+ await fs.remove(dir);
668
+ }
669
+ dir = path.dirname(dir);
670
+ }
671
+ // still throw the error so we retry
672
+ }
673
+ throw error;
590
674
  }
591
675
  }, {
592
676
  // windows tends to run into these issues way more often than
@@ -594,7 +678,7 @@ export class FileSync {
594
678
  retries: config.windows ? 4 : 2,
595
679
  minTimeout: ms("100ms"),
596
680
  onFailedAttempt: (error)=>{
597
- this.ctx.log.warn("failed to move file to backup", {
681
+ ctx.log.warn("failed to move file to backup", {
598
682
  error,
599
683
  currentPath,
600
684
  backupPath
@@ -602,12 +686,38 @@ export class FileSync {
602
686
  }
603
687
  });
604
688
  });
689
+ for (const directoryWithDeletedFile of Array.from(directoriesWithDeletedFiles.values()).sort().reverse()){
690
+ if (options.files.some((file)=>file.path === directoryWithDeletedFile)) {
691
+ continue;
692
+ }
693
+ try {
694
+ // delete any empty directories that contained a deleted file.
695
+ // if the empty directory should continue to exist, we would
696
+ // have received an event to create it above
697
+ await fs.rmdir(this.syncJson.directory.absolute(directoryWithDeletedFile));
698
+ changes.set(directoryWithDeletedFile, {
699
+ type: "delete"
700
+ });
701
+ } catch (error) {
702
+ if (isENOENTError(error) || isENOTEMPTYError(error)) {
703
+ continue;
704
+ }
705
+ throw error;
706
+ }
707
+ }
605
708
  await pMap(options.files, async (file)=>{
606
- const absolutePath = this.directory.absolute(file.path);
709
+ const absolutePath = this.syncJson.directory.absolute(file.path);
607
710
  if (await fs.pathExists(absolutePath)) {
608
- updated.push(file.path);
711
+ if (!file.path.endsWith("/")) {
712
+ // only track file updates, not directory updates
713
+ changes.set(file.path, {
714
+ type: "update"
715
+ });
716
+ }
609
717
  } else {
610
- created.push(file.path);
718
+ changes.set(file.path, {
719
+ type: "create"
720
+ });
611
721
  }
612
722
  if (file.path.endsWith("/")) {
613
723
  await fs.ensureDir(absolutePath);
@@ -620,167 +730,57 @@ export class FileSync {
620
730
  // ensure the file has the correct mode
621
731
  await fs.chmod(absolutePath, file.mode & 0o777);
622
732
  }
623
- if (absolutePath === this.directory.absolute(".ignore")) {
624
- await this.directory.loadIgnoreFile();
733
+ if (absolutePath === this.syncJson.directory.absolute(".ignore")) {
734
+ await this.syncJson.directory.loadIgnoreFile();
625
735
  }
626
736
  });
627
- await this._save(String(filesVersion));
628
- const changes = new Changes([
629
- ...created.map((path)=>[
630
- path,
631
- {
632
- type: "create"
633
- }
634
- ]),
635
- ...updated.map((path)=>[
636
- path,
637
- {
638
- type: "update"
639
- }
640
- ]),
641
- ...options.delete.map((path)=>[
642
- path,
643
- {
644
- type: "delete"
645
- }
646
- ])
647
- ]);
737
+ await this.syncJson.save(String(filesVersion));
738
+ options.spinner?.clear();
739
+ printChanges(ctx, {
740
+ changes,
741
+ tense: "past",
742
+ title: sprint`{green ${symbol.tick}} Pulled ${pluralize("file", changes.size)}. ${symbol.arrowLeft} ${ts()}`,
743
+ ...options.printEnvironmentChangesOptions
744
+ });
648
745
  if (changes.has("yarn.lock")) {
649
- this.ctx.log.info("running yarn install --check-files");
650
- await execa("yarn", [
651
- "install",
652
- "--check-files"
653
- ], {
654
- cwd: this.directory.path
655
- }).then(()=>this.ctx.log.info("yarn install complete")).catch((error)=>this.ctx.log.error("yarn install failed", {
746
+ const spinner = spin({
747
+ ensureEmptyLineAbove: true
748
+ })('Running "yarn install --check-files"');
749
+ try {
750
+ await execa("yarn", [
751
+ "install",
752
+ "--check-files"
753
+ ], {
754
+ cwd: this.syncJson.directory.path
755
+ });
756
+ spinner.succeed`Ran "yarn install --check-files" ${ts()}`;
757
+ } catch (error) {
758
+ spinner.fail();
759
+ ctx.log.error("yarn install failed", {
656
760
  error
657
- }));
761
+ });
762
+ const message = serializeError(error).message;
763
+ if (message) {
764
+ println({
765
+ ensureEmptyLineAbove: true,
766
+ indent: 2
767
+ })(message);
768
+ }
769
+ }
658
770
  }
659
771
  return changes;
660
772
  }
661
- /**
662
- * Updates {@linkcode _syncJson} and saves it to `.gadget/sync.json`.
663
- */ async _save(filesVersion) {
664
- this._syncJson = {
665
- ...this._syncJson,
666
- mtime: Date.now() + 1,
667
- filesVersion: String(filesVersion)
668
- };
669
- this.ctx.log.debug("saving .gadget/sync.json");
670
- await fs.outputJSON(this.directory.absolute(".gadget/sync.json"), this._syncJson, {
671
- spaces: 2
672
- });
673
- }
674
- constructor(/**
675
- * The {@linkcode Context} that was used to initialize this
676
- * {@linkcode FileSync} instance.
677
- */ ctx, /**
678
- * The directory that is being synced to.
679
- */ directory, /**
680
- * The Gadget application that is being synced to.
681
- */ app, /**
682
- * The state of the filesystem.
683
- *
684
- * This is persisted to `.gadget/sync.json` within the {@linkcode directory}.
685
- */ _syncJson){
686
- _define_property(this, "ctx", void 0);
687
- _define_property(this, "directory", void 0);
688
- _define_property(this, "app", void 0);
689
- _define_property(this, "_syncJson", void 0);
690
- _define_property(this, "edit", void 0);
773
+ constructor(syncJson){
774
+ _define_property(this, "syncJson", void 0);
691
775
  /**
692
776
  * A FIFO async callback queue that ensures we process filesync events
693
777
  * in the order we receive them.
694
778
  */ _define_property(this, "_syncOperations", void 0);
695
- this.ctx = ctx;
696
- this.directory = directory;
697
- this.app = app;
698
- this._syncJson = _syncJson;
779
+ this.syncJson = syncJson;
699
780
  this._syncOperations = new PQueue({
700
781
  concurrency: 1
701
782
  });
702
- this.ctx = ctx.child({
703
- fields: ()=>({
704
- filesync: {
705
- directory: this.directory.path,
706
- filesVersion: this.filesVersion
707
- }
708
- })
709
- });
710
- this.edit = new Edit(this.ctx);
711
783
  }
712
784
  }
713
- /**
714
- * Checks if a directory is empty or non-existent.
715
- *
716
- * @param dir - The directory path to check.
717
- * @returns A Promise that resolves to a boolean indicating whether the directory is empty or non-existent.
718
- */ export const isEmptyOrNonExistentDir = async (dir)=>{
719
- try {
720
- for await (const _ of (await fs.opendir(dir, {
721
- bufferSize: 1
722
- }))){
723
- return false;
724
- }
725
- return true;
726
- } catch (error) {
727
- swallowEnoent(error);
728
- return true;
729
- }
730
- };
731
- export const assertAllGadgetFiles = ({ gadgetChanges })=>{
732
- assert(gadgetChanges.created().length > 0 || gadgetChanges.deleted().length > 0 || gadgetChanges.updated().length > 0, "expected gadgetChanges to have changes");
733
- const allGadgetFiles = Array.from(gadgetChanges.keys()).every((path)=>path.startsWith(".gadget/"));
734
- assert(allGadgetFiles, "expected all gadgetChanges to be .gadget/ files");
735
- };
736
- export const ConflictPreference = Object.freeze({
737
- CANCEL: "Cancel (Ctrl+C)",
738
- LOCAL: "Keep my conflicting changes",
739
- GADGET: "Keep Gadget's conflicting changes"
740
- });
741
- export const ConflictPreferenceArg = (value, name)=>{
742
- if ([
743
- "local",
744
- "gadget"
745
- ].includes(value)) {
746
- return ConflictPreference[value.toUpperCase()];
747
- }
748
- throw new ArgError(sprint`
749
- ${name} must be {bold local} or {bold gadget}
750
-
751
- {bold EXAMPLES:}
752
- ${name} local
753
- ${name} gadget
754
- `);
755
- };
756
- export const FileSyncArgs = {
757
- "--app": {
758
- type: AppArg,
759
- alias: "-a"
760
- },
761
- "--prefer": ConflictPreferenceArg,
762
- "--force": Boolean
763
- };
764
- export const isFilesVersionMismatchError = (error)=>{
765
- if (error instanceof EditError) {
766
- error = error.cause;
767
- }
768
- if (isGraphQLResult(error)) {
769
- error = error.errors;
770
- }
771
- if (isGraphQLErrors(error)) {
772
- error = error[0];
773
- }
774
- return isObject(error) && "message" in error && isString(error.message) && error.message.includes("Files version mismatch");
775
- };
776
- const swallowFilesVersionMismatch = (ctx, error)=>{
777
- if (isFilesVersionMismatchError(error)) {
778
- ctx.log.debug("swallowing files version mismatch", {
779
- error
780
- });
781
- return;
782
- }
783
- throw error;
784
- };
785
785
 
786
786
  //# sourceMappingURL=filesync.js.map