@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.
- package/README.md +165 -93
- package/lib/__generated__/graphql.js +66 -1
- package/lib/__generated__/graphql.js.map +1 -1
- package/lib/commands/deploy.js +328 -230
- package/lib/commands/deploy.js.map +1 -1
- package/lib/commands/dev.js +445 -0
- package/lib/commands/dev.js.map +1 -0
- package/lib/commands/list.js +27 -19
- package/lib/commands/list.js.map +1 -1
- package/lib/commands/login.js +15 -11
- package/lib/commands/login.js.map +1 -1
- package/lib/commands/logout.js +5 -5
- package/lib/commands/logout.js.map +1 -1
- package/lib/commands/open.js +200 -0
- package/lib/commands/open.js.map +1 -0
- package/lib/commands/pull.js +128 -0
- package/lib/commands/pull.js.map +1 -0
- package/lib/commands/push.js +126 -0
- package/lib/commands/push.js.map +1 -0
- package/lib/commands/root.js +46 -28
- package/lib/commands/root.js.map +1 -1
- package/lib/commands/status.js +61 -0
- package/lib/commands/status.js.map +1 -0
- package/lib/commands/version.js +6 -6
- package/lib/commands/version.js.map +1 -1
- package/lib/commands/whoami.js +6 -6
- package/lib/commands/whoami.js.map +1 -1
- package/lib/ggt.js +33 -8
- package/lib/ggt.js.map +1 -1
- package/lib/main.js +5 -0
- package/lib/main.js.map +1 -0
- package/lib/services/app/api/api.js +191 -0
- package/lib/services/app/api/api.js.map +1 -0
- package/lib/services/app/api/operation.js +12 -0
- package/lib/services/app/api/operation.js.map +1 -0
- package/lib/services/app/app.js +44 -10
- package/lib/services/app/app.js.map +1 -1
- package/lib/services/app/{edit/client.js → client.js} +29 -19
- package/lib/services/app/client.js.map +1 -0
- package/lib/services/app/edit/edit.js +67 -31
- package/lib/services/app/edit/edit.js.map +1 -1
- package/lib/services/app/edit/operation.js +4 -3
- package/lib/services/app/edit/operation.js.map +1 -1
- package/lib/services/app/{edit/error.js → error.js} +6 -6
- package/lib/services/app/error.js.map +1 -0
- package/lib/services/command/arg.js +4 -4
- package/lib/services/command/arg.js.map +1 -1
- package/lib/services/command/command.js +9 -7
- package/lib/services/command/command.js.map +1 -1
- package/lib/services/command/context.js +82 -20
- package/lib/services/command/context.js.map +1 -1
- package/lib/services/config/config.js +4 -7
- package/lib/services/config/config.js.map +1 -1
- package/lib/services/config/env.js +1 -1
- package/lib/services/config/env.js.map +1 -1
- package/lib/services/filesync/changes.js +76 -37
- package/lib/services/filesync/changes.js.map +1 -1
- package/lib/services/filesync/conflicts.js +10 -9
- package/lib/services/filesync/conflicts.js.map +1 -1
- package/lib/services/filesync/directory.js +16 -1
- package/lib/services/filesync/directory.js.map +1 -1
- package/lib/services/filesync/error.js +96 -27
- package/lib/services/filesync/error.js.map +1 -1
- package/lib/services/filesync/filesync.js +516 -516
- package/lib/services/filesync/filesync.js.map +1 -1
- package/lib/services/filesync/hashes.js +8 -5
- package/lib/services/filesync/hashes.js.map +1 -1
- package/lib/services/filesync/strategy.js +59 -0
- package/lib/services/filesync/strategy.js.map +1 -0
- package/lib/services/filesync/sync-json.js +475 -0
- package/lib/services/filesync/sync-json.js.map +1 -0
- package/lib/services/http/auth.js +30 -1
- package/lib/services/http/auth.js.map +1 -1
- package/lib/services/http/http.js +5 -0
- package/lib/services/http/http.js.map +1 -1
- package/lib/services/output/confirm.js +149 -0
- package/lib/services/output/confirm.js.map +1 -0
- package/lib/services/output/footer.js +22 -0
- package/lib/services/output/footer.js.map +1 -0
- package/lib/services/output/log/format/pretty.js +2 -1
- package/lib/services/output/log/format/pretty.js.map +1 -1
- package/lib/services/output/log/logger.js +13 -5
- package/lib/services/output/log/logger.js.map +1 -1
- package/lib/services/output/log/structured.js +2 -2
- package/lib/services/output/log/structured.js.map +1 -1
- package/lib/services/output/output.js +197 -0
- package/lib/services/output/output.js.map +1 -0
- package/lib/services/output/print.js +31 -0
- package/lib/services/output/print.js.map +1 -0
- package/lib/services/output/problems.js +84 -0
- package/lib/services/output/problems.js.map +1 -0
- package/lib/services/output/prompt.js +173 -40
- package/lib/services/output/prompt.js.map +1 -1
- package/lib/services/output/report.js +63 -19
- package/lib/services/output/report.js.map +1 -1
- package/lib/services/output/select.js +198 -0
- package/lib/services/output/select.js.map +1 -0
- package/lib/services/output/spinner.js +141 -0
- package/lib/services/output/spinner.js.map +1 -0
- package/lib/services/output/sprint.js +38 -15
- package/lib/services/output/sprint.js.map +1 -1
- package/lib/services/output/symbols.js +23 -0
- package/lib/services/output/symbols.js.map +1 -0
- package/lib/services/output/table.js +98 -0
- package/lib/services/output/table.js.map +1 -0
- package/lib/services/output/timestamp.js +12 -0
- package/lib/services/output/timestamp.js.map +1 -0
- package/lib/services/output/update.js +29 -9
- package/lib/services/output/update.js.map +1 -1
- package/lib/services/user/session.js +4 -0
- package/lib/services/user/session.js.map +1 -1
- package/lib/services/user/user.js +15 -10
- package/lib/services/user/user.js.map +1 -1
- package/lib/services/util/assert.js +11 -0
- package/lib/services/util/assert.js.map +1 -0
- package/lib/services/util/boolean.js +2 -2
- package/lib/services/util/boolean.js.map +1 -1
- package/lib/services/util/function.js +45 -7
- package/lib/services/util/function.js.map +1 -1
- package/lib/services/util/is.js +35 -2
- package/lib/services/util/is.js.map +1 -1
- package/lib/services/util/json.js +16 -13
- package/lib/services/util/json.js.map +1 -1
- package/lib/services/util/object.js +2 -2
- package/lib/services/util/object.js.map +1 -1
- package/lib/services/util/promise.js +5 -2
- package/lib/services/util/promise.js.map +1 -1
- package/lib/services/util/types.js.map +1 -1
- package/npm-shrinkwrap.json +3425 -2983
- package/package.json +48 -41
- package/bin/dev.cmd +0 -3
- package/bin/dev.js +0 -14
- package/bin/run.cmd +0 -3
- package/bin/run.js +0 -5
- package/lib/commands/sync.js +0 -284
- package/lib/commands/sync.js.map +0 -1
- package/lib/services/app/edit/client.js.map +0 -1
- package/lib/services/app/edit/error.js.map +0 -1
- package/lib/services/output/log/printer.js +0 -120
- package/lib/services/output/log/printer.js.map +0 -1
- package/lib/services/output/stream.js +0 -54
- 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
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
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 {
|
|
28
|
-
import {
|
|
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 {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
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
|
|
200
|
+
*/ async mergeChangesWithEnvironment(ctx, { changes, printLocalChangesOptions, printEnvironmentChangesOptions }) {
|
|
179
201
|
await this._syncOperations.add(async ()=>{
|
|
180
202
|
try {
|
|
181
|
-
await this.
|
|
182
|
-
changes
|
|
203
|
+
await this._sendChangesToEnvironment(ctx, {
|
|
204
|
+
changes,
|
|
205
|
+
printLocalChangesOptions
|
|
183
206
|
});
|
|
184
207
|
} catch (error) {
|
|
185
|
-
swallowFilesVersionMismatch(
|
|
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.
|
|
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
|
-
*/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
297
|
+
*/ async merge(ctx, { hashes, maxAttempts = 10, printLocalChangesOptions, printEnvironmentChangesOptions } = {}) {
|
|
272
298
|
let attempt = 0;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
311
|
+
attempt += 1;
|
|
312
|
+
ctx.log.info("merging", {
|
|
313
|
+
attempt,
|
|
314
|
+
...hashes
|
|
315
|
+
});
|
|
287
316
|
try {
|
|
288
|
-
this.ctx
|
|
289
|
-
|
|
290
|
-
|
|
317
|
+
await this._merge(ctx, {
|
|
318
|
+
hashes,
|
|
319
|
+
printLocalChangesOptions,
|
|
320
|
+
printEnvironmentChangesOptions
|
|
291
321
|
});
|
|
292
|
-
await this._sync(hashes);
|
|
293
322
|
} catch (error) {
|
|
294
|
-
swallowFilesVersionMismatch(
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
+
environmentChanges
|
|
329
391
|
});
|
|
330
392
|
if (conflicts.size > 0) {
|
|
331
|
-
|
|
393
|
+
ctx.log.debug("conflicts detected", {
|
|
332
394
|
conflicts
|
|
333
395
|
});
|
|
334
|
-
let preference =
|
|
396
|
+
let preference = ctx.args["--prefer"];
|
|
335
397
|
if (!preference) {
|
|
336
|
-
printConflicts(
|
|
337
|
-
message: sprint`{bold You have conflicting changes with Gadget}`,
|
|
398
|
+
printConflicts({
|
|
338
399
|
conflicts
|
|
339
400
|
});
|
|
340
|
-
preference = await select(
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
408
|
+
case MergeConflictPreference.CANCEL:
|
|
347
409
|
{
|
|
348
410
|
process.exit(0);
|
|
349
411
|
break;
|
|
350
412
|
}
|
|
351
|
-
case
|
|
413
|
+
case MergeConflictPreference.LOCAL:
|
|
352
414
|
{
|
|
353
|
-
|
|
415
|
+
environmentChanges = withoutConflictingChanges({
|
|
354
416
|
conflicts,
|
|
355
|
-
changes:
|
|
417
|
+
changes: environmentChanges
|
|
356
418
|
});
|
|
357
419
|
break;
|
|
358
420
|
}
|
|
359
|
-
case
|
|
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 (
|
|
370
|
-
await this.
|
|
371
|
-
changes:
|
|
372
|
-
filesVersion:
|
|
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.
|
|
439
|
+
await this._sendChangesToEnvironment(ctx, {
|
|
377
440
|
changes: localChanges,
|
|
378
|
-
expectedFilesVersion:
|
|
441
|
+
expectedFilesVersion: environmentFilesVersion,
|
|
442
|
+
printLocalChangesOptions
|
|
379
443
|
});
|
|
380
444
|
}
|
|
381
445
|
}
|
|
382
|
-
async
|
|
383
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
+
ctx.log.debug("skipping send because there are no changes");
|
|
505
532
|
return;
|
|
506
533
|
}
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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
|
|
573
|
-
const
|
|
574
|
-
await pMap(options.delete, async (
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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.
|
|
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
|