querysub 0.675.0 → 0.677.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.675.0",
3
+ "version": "0.677.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -0,0 +1,256 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import readline from "readline";
4
+ import open from "open";
5
+ import { runOverSSH } from "sliftutils/security/helpers/remoteSSH";
6
+ import { fsExistsAsync } from "../fs";
7
+
8
+ const SSH_CONFIG_INCLUDE_LINE = "Include ~/.ssh/config.d/*";
9
+ const MAX_ERROR_BODY_LENGTH = 500;
10
+ // How often a pending question is re-printed, as other logging keeps running while we wait and buries it
11
+ const QUESTION_REMIND_INTERVAL = 15 * 1000;
12
+
13
+ /** Gives a remote machine read access to a private github repo, by giving that repo a deploy key of its own on that machine. Github will not take the same public key on two repos, so this key cannot be the machine's ~/.ssh/id_rsa - and ssh only chooses a key per host, not per repo, so the url is rewritten onto a host alias (global git config) which maps back to github.com with that key (ssh config). Both are global on the machine, so every clone on it picks this up, the service checkouts and their yarn installs included. */
14
+ export async function ensureRemoteRepoAccess(config: { sshRemote: string; repoURL: string }) {
15
+ let { sshRemote, repoURL } = config;
16
+ let { owner, name } = parseGitHubRepo(repoURL);
17
+ let hostAlias = `github.com-${owner}-${name}`;
18
+ let keyPath = `~/.ssh/id_github_${owner}_${name}`;
19
+ let aliasPath = `~/.ssh/config.d/${owner}-${name}`;
20
+ let aliasURL = `git@${hostAlias}:${owner}/${name}.git`;
21
+
22
+ console.log(`Giving ${sshRemote} access to ${owner}/${name}, with the key at ${keyPath}`);
23
+ await runOverSSH({
24
+ host: sshRemote,
25
+ script: `set -e
26
+ mkdir -p ~/.ssh/config.d
27
+ chmod 700 ~/.ssh ~/.ssh/config.d
28
+ touch ~/.ssh/config
29
+ chmod 600 ~/.ssh/config
30
+ ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null || true
31
+ if [ ! -f ${keyPath} ]; then
32
+ ssh-keygen -t ed25519 -N '' -f ${keyPath} -C "${owner}/${name} deploy key"
33
+ fi
34
+ cat > ${aliasPath} <<'QUERYSUB_SSH_CONFIG_BLOCK'
35
+ Host ${hostAlias}
36
+ HostName github.com
37
+ HostKeyAlias github.com
38
+ User git
39
+ IdentityFile ${keyPath}
40
+ IdentitiesOnly yes
41
+ QUERYSUB_SSH_CONFIG_BLOCK
42
+ chmod 600 ${aliasPath}
43
+ if ! grep -qxF '${SSH_CONFIG_INCLUDE_LINE}' ~/.ssh/config; then
44
+ printf '${SSH_CONFIG_INCLUDE_LINE}\\n\\n' | cat - ~/.ssh/config > ~/.ssh/config.querysub-new
45
+ mv ~/.ssh/config.querysub-new ~/.ssh/config
46
+ chmod 600 ~/.ssh/config
47
+ fi
48
+ git config --global --replace-all "url.${aliasURL}.insteadOf" "git@github.com:${owner}/${name}.git"
49
+ git config --global --replace-all "url.ssh://git@${hostAlias}/${owner}/${name}.git.insteadOf" "ssh://git@github.com/${owner}/${name}.git"`,
50
+ });
51
+
52
+ let probe = await runOverSSH({ host: sshRemote, script: `git ls-remote ${aliasURL} HEAD`, allowFailure: true });
53
+ if (probe.status === 0) {
54
+ console.log(`✅ ${sshRemote} can already read ${owner}/${name}, so its deploy key is left as it is`);
55
+ return;
56
+ }
57
+ console.log(`${sshRemote} cannot read ${owner}/${name} yet, adding its key as a deploy key`);
58
+
59
+ let publicKey = (await runOverSSH({ host: sshRemote, script: `cat ${keyPath}.pub` })).stdout.trim().split("\n").at(-1) || "";
60
+ if (!publicKey.startsWith("ssh-")) {
61
+ throw new Error(`Expected a public key at ${keyPath}.pub on ${sshRemote}, was ${publicKey.slice(0, MAX_ERROR_BODY_LENGTH) || "(nothing)"}`);
62
+ }
63
+ await addDeployKeyToGitHub({
64
+ sshPublicKey: publicKey,
65
+ keyTitle: `Machine Setup - ${sshRemote} - ${new Date().toISOString()}`,
66
+ repoURL,
67
+ sshRemote,
68
+ });
69
+
70
+ let recheck = await runOverSSH({ host: sshRemote, script: `git ls-remote ${aliasURL} HEAD`, allowFailure: true });
71
+ if (recheck.status !== 0) {
72
+ throw new Error(
73
+ `Expected ${sshRemote} to read ${owner}/${name} after its deploy key was added, git ls-remote ${aliasURL} exited ${recheck.status}.\n`
74
+ + `${(recheck.stdout + recheck.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
75
+ );
76
+ }
77
+ console.log(`✅ ${sshRemote} can now read ${owner}/${name}`);
78
+ }
79
+
80
+ export function parseGitHubRepo(repoURL: string): { owner: string; name: string } {
81
+ let cleaned = repoURL.replace(/^git\+/, "").split("#")[0];
82
+ let match = cleaned.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/);
83
+ if (!match) {
84
+ throw new Error(`Expected a github repository url, was ${JSON.stringify(repoURL.slice(0, MAX_ERROR_BODY_LENGTH))}`);
85
+ }
86
+ return { owner: match[1], name: match[2] };
87
+ }
88
+
89
+ export async function addDeployKeyToGitHub(config: {
90
+ sshPublicKey: string;
91
+ keyTitle: string;
92
+ repoURL: string;
93
+ sshRemote: string;
94
+ }): Promise<void> {
95
+ let { sshPublicKey, keyTitle, repoURL, sshRemote } = config;
96
+ let { owner, name } = parseGitHubRepo(repoURL);
97
+
98
+ let url = `https://api.github.com/repos/${owner}/${name}/keys`;
99
+ console.log(url);
100
+
101
+ let forceRefresh = false;
102
+ while (true) {
103
+ const apiKey = await getGitHubApiKey({ repoURL, sshRemote, forceRefresh });
104
+ const response = await fetch(url, {
105
+ method: "POST",
106
+ headers: {
107
+ "Authorization": `Bearer ${apiKey}`,
108
+ "Accept": "application/vnd.github+json",
109
+ "Content-Type": "application/json"
110
+ },
111
+ body: JSON.stringify({
112
+ title: keyTitle,
113
+ key: sshPublicKey,
114
+ read_only: true
115
+ })
116
+ });
117
+
118
+ if (response.ok) {
119
+ console.log("✅ Deploy key added to GitHub repository");
120
+ return;
121
+ }
122
+
123
+ const errorText = await response.text();
124
+ if (response.status === 401 && !forceRefresh) {
125
+ console.warn(`⚠️ GitHub API rejected credentials (401). Invalidating cached token and re-prompting.`);
126
+ try {
127
+ fs.unlinkSync(getGitHubKeyCachePath(repoURL));
128
+ } catch {
129
+ // Cache file may not exist; ignore
130
+ }
131
+ forceRefresh = true;
132
+ continue;
133
+ }
134
+ throw new Error(`Failed to add deploy key to GitHub repository: ${response.status} ${errorText}`);
135
+ }
136
+ }
137
+
138
+ export async function getGitHubApiKey(config: { repoURL: string; sshRemote: string; forceRefresh?: boolean }): Promise<string> {
139
+ let { repoURL, sshRemote, forceRefresh } = config;
140
+ let { owner, name } = parseGitHubRepo(repoURL);
141
+ const cacheFile = getGitHubKeyCachePath(repoURL);
142
+
143
+ // Check if we have a cached key
144
+ if (!forceRefresh && await fsExistsAsync(cacheFile)) {
145
+ try {
146
+ const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")) as { apiKey?: string };
147
+ if (cached.apiKey) {
148
+ if (await verifyGitHubApiKey(cached.apiKey)) {
149
+ console.log(`✅ Using cached GitHub API key from ${cacheFile}`);
150
+ return cached.apiKey;
151
+ }
152
+ console.warn(`⚠️ Cached GitHub API key at ${cacheFile} failed verification (401 Bad credentials) — requesting a new one`);
153
+ }
154
+ } catch {
155
+ // Invalid cache file, we'll ask for a new key
156
+ }
157
+ }
158
+
159
+ // A key that is already cached for another repo is very likely to work here too, as the scope is per account rather than per repo
160
+ if (!forceRefresh) {
161
+ let existing = await findApiKeyWithAccess({ owner, name, exceptPath: cacheFile });
162
+ if (existing) {
163
+ fs.writeFileSync(cacheFile, JSON.stringify({ apiKey: existing.apiKey }));
164
+ console.log(`✅ Reusing the GitHub API key from ${existing.path}, which has access to ${owner}/${name}, and caching it to ${cacheFile}`);
165
+ return existing.apiKey;
166
+ }
167
+ }
168
+
169
+ // Need to get a new API key from user
170
+ console.log("\n🔑 GitHub API key required for private repository access");
171
+ console.log("Opening GitHub token creation page...");
172
+
173
+ // Construct URL for classic token (fine-grained tokens don't support deploy keys)
174
+ const instructions = `yarn setup-machine ${sshRemote} for repository ${owner}/${name}
175
+ 1) Set expiration to 'No expiration'
176
+ 2) Check the 'repo' scope (full repository access)
177
+ 3) Click 'Generate token'`;
178
+
179
+ let tokenUrl = `https://github.com/settings/tokens/new?description=${encodeURIComponent(instructions)}&scopes=repo`;
180
+ console.log(`Setting up access for repository: ${owner}/${name}`);
181
+
182
+ await open(tokenUrl);
183
+
184
+ const apiKey = await askQuestion(`Please paste your GitHub API token (for repository ${owner}/${name}): `);
185
+
186
+ // Cache the key
187
+ fs.writeFileSync(cacheFile, JSON.stringify({ apiKey }));
188
+ console.log(`✅ Caching GitHub API key to ${cacheFile}`);
189
+
190
+ return apiKey;
191
+ }
192
+
193
+ async function findApiKeyWithAccess(config: { owner: string; name: string; exceptPath: string }): Promise<{ apiKey: string; path: string } | undefined> {
194
+ let { owner, name, exceptPath } = config;
195
+ let homeDir = os.homedir();
196
+ let fileNames = await fs.promises.readdir(homeDir);
197
+ for (let fileName of fileNames) {
198
+ if (!fileName.startsWith("githubkey_") || !fileName.endsWith(".json")) continue;
199
+ let path = `${homeDir}/${fileName}`;
200
+ if (path === exceptPath) continue;
201
+ let apiKey = "";
202
+ try {
203
+ apiKey = (JSON.parse(await fs.promises.readFile(path, "utf8")) as { apiKey?: string }).apiKey || "";
204
+ } catch {
205
+ continue;
206
+ }
207
+ if (!apiKey) continue;
208
+ let response = await fetch(`https://api.github.com/repos/${owner}/${name}`, {
209
+ headers: {
210
+ "Authorization": `Bearer ${apiKey}`,
211
+ "Accept": "application/vnd.github+json"
212
+ }
213
+ });
214
+ if (!response.ok) continue;
215
+ let repo = await response.json() as { permissions?: { admin?: boolean } };
216
+ if (!repo.permissions?.admin) continue;
217
+ return { apiKey, path };
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ export function getGitHubKeyCachePath(repoURL: string): string {
223
+ let { owner, name } = parseGitHubRepo(repoURL);
224
+ return os.homedir() + `/githubkey_${owner}_${name}.json`;
225
+ }
226
+
227
+ export async function verifyGitHubApiKey(apiKey: string): Promise<boolean> {
228
+ const response = await fetch("https://api.github.com/user", {
229
+ headers: {
230
+ "Authorization": `Bearer ${apiKey}`,
231
+ "Accept": "application/vnd.github+json"
232
+ }
233
+ });
234
+ return response.ok;
235
+ }
236
+
237
+ export async function askQuestion(prompt: string): Promise<string> {
238
+ const rl = readline.createInterface({
239
+ // Cast, as different @types/node versions disagree on the stream types
240
+ input: process.stdin as unknown as NodeJS.ReadableStream,
241
+ output: process.stdout,
242
+ });
243
+ let remind = setInterval(() => {
244
+ console.log(`\n(still waiting for an answer)\n${prompt}`);
245
+ }, QUESTION_REMIND_INTERVAL);
246
+ try {
247
+ return await new Promise<string>(resolve => {
248
+ rl.question(prompt, answer => {
249
+ resolve(answer.trim());
250
+ });
251
+ });
252
+ } finally {
253
+ clearInterval(remind);
254
+ rl.close();
255
+ }
256
+ }
@@ -2,14 +2,11 @@ import { getBackblazePath } from "../misc/appPaths";
2
2
  import { getGitURLLive, getGitRefLive } from "../4-deploy/git";
3
3
  import { Querysub } from "../4-querysub/Querysub";
4
4
  import { runPromise } from "../functional/runCommand";
5
- import fs from "fs";
6
- import os from "os";
7
5
  import path from "path";
8
- import readline from "readline";
9
- import open from "open";
10
6
  import { fsExistsAsync } from "../fs";
11
7
  import { delay } from "socket-function/src/batching";
12
8
  import { SERVICE_NAME, SERVICE_UNIT_NAME } from "./machineDaemonShared";
9
+ import { addDeployKeyToGitHub, askQuestion } from "./githubRepoAccess";
13
10
  // Import querysub, to fix missing dependencies
14
11
  Querysub;
15
12
 
@@ -145,174 +142,6 @@ async function installUnofficialNode(sshRemote: string, major: number): Promise<
145
142
  }
146
143
  }
147
144
 
148
- // How often a pending question is re-printed, as other logging keeps running while we wait and buries it
149
- const QUESTION_REMIND_INTERVAL = 15 * 1000;
150
- async function askQuestion(prompt: string): Promise<string> {
151
- const rl = readline.createInterface({
152
- // Cast, as different @types/node versions disagree on the stream types
153
- input: process.stdin as unknown as NodeJS.ReadableStream,
154
- output: process.stdout,
155
- });
156
- let remind = setInterval(() => {
157
- console.log(`\n(still waiting for an answer)\n${prompt}`);
158
- }, QUESTION_REMIND_INTERVAL);
159
- try {
160
- return await new Promise<string>(resolve => {
161
- rl.question(prompt, answer => {
162
- resolve(answer.trim());
163
- });
164
- });
165
- } finally {
166
- clearInterval(remind);
167
- rl.close();
168
- }
169
- }
170
-
171
- function getGitHubKeyCachePath(repoUrl: string): string {
172
- let repoOwner = "";
173
- let repoName = "";
174
- const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+)\.git$/);
175
- const httpsMatch = repoUrl.match(/https:\/\/github\.com\/([^/]+)\/(.+)\.git$/);
176
- if (sshMatch) {
177
- repoOwner = sshMatch[1];
178
- repoName = sshMatch[2];
179
- } else if (httpsMatch) {
180
- repoOwner = httpsMatch[1];
181
- repoName = httpsMatch[2];
182
- }
183
- return os.homedir() + `/githubkey_${repoOwner}_${repoName}.json`;
184
- }
185
-
186
- async function verifyGitHubApiKey(apiKey: string): Promise<boolean> {
187
- const response = await fetch("https://api.github.com/user", {
188
- headers: {
189
- "Authorization": `Bearer ${apiKey}`,
190
- "Accept": "application/vnd.github+json"
191
- }
192
- });
193
- return response.ok;
194
- }
195
-
196
- async function getGitHubApiKey(repoUrl: string, sshRemote: string, forceRefresh = false): Promise<string> {
197
- // Parse repository info from URL
198
- let repoOwner = "";
199
- let repoName = "";
200
- // Handle both SSH and HTTPS formats
201
- // SSH: git@github.com:owner/repo.git
202
- // HTTPS: https://github.com/owner/repo.git
203
- const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+)\.git$/);
204
- const httpsMatch = repoUrl.match(/https:\/\/github\.com\/([^/]+)\/(.+)\.git$/);
205
-
206
- if (sshMatch) {
207
- repoOwner = sshMatch[1];
208
- repoName = sshMatch[2];
209
- } else if (httpsMatch) {
210
- repoOwner = httpsMatch[1];
211
- repoName = httpsMatch[2];
212
- }
213
-
214
- const cacheFile = getGitHubKeyCachePath(repoUrl);
215
-
216
- // Check if we have a cached key
217
- if (!forceRefresh && await fsExistsAsync(cacheFile)) {
218
- try {
219
- const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
220
- if (cached.apiKey) {
221
- if (await verifyGitHubApiKey(cached.apiKey)) {
222
- console.log(`✅ Using cached GitHub API key from ${cacheFile}`);
223
- return cached.apiKey;
224
- }
225
- console.warn(`⚠️ Cached GitHub API key at ${cacheFile} failed verification (401 Bad credentials) — requesting a new one`);
226
- }
227
- } catch {
228
- // Invalid cache file, we'll ask for a new key
229
- }
230
- }
231
-
232
- // Need to get a new API key from user
233
- console.log("\n🔑 GitHub API key required for private repository access");
234
- console.log("Opening GitHub token creation page...");
235
-
236
- // Construct URL for classic token (fine-grained tokens don't support deploy keys)
237
- const repoInfo = repoOwner && repoName ? ` for repository ${repoOwner}/${repoName}` : "";
238
- const instructions = `yarn setup-machine ${sshRemote}${repoInfo}
239
- 1) Set expiration to 'No expiration'
240
- 2) Check the 'repo' scope (full repository access)
241
- 3) Click 'Generate token'`;
242
-
243
- let tokenUrl = `https://github.com/settings/tokens/new?description=${encodeURIComponent(instructions)}&scopes=repo`;
244
- if (repoOwner && repoName) {
245
- console.log(`Setting up access for repository: ${repoOwner}/${repoName}`);
246
- }
247
-
248
- await open(tokenUrl);
249
-
250
- const apiKey = await askQuestion(`Please paste your GitHub API token (${repoOwner && repoName && `for repository ${repoOwner}/${repoName}` || ""}): `);
251
-
252
- // Cache the key
253
- fs.writeFileSync(cacheFile, JSON.stringify({ apiKey }));
254
- console.log(`✅ Caching GitHub API key to ${cacheFile}`);
255
-
256
- return apiKey;
257
- }
258
-
259
- async function addDeployKeyToGitHub(sshPublicKey: string, keyTitle: string, repoUrl: string, sshRemote: string): Promise<void> {
260
- // Parse repository info from URL to get owner/repo for the API endpoint
261
- let repoOwner = "";
262
- let repoName = "";
263
- const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+)\.git$/);
264
- const httpsMatch = repoUrl.match(/https:\/\/github\.com\/([^/]+)\/(.+)\.git$/);
265
-
266
- if (sshMatch) {
267
- repoOwner = sshMatch[1];
268
- repoName = sshMatch[2];
269
- } else if (httpsMatch) {
270
- repoOwner = httpsMatch[1];
271
- repoName = httpsMatch[2];
272
- } else {
273
- throw new Error(`Could not parse GitHub repository from URL: ${repoUrl}`);
274
- }
275
-
276
- let url = `https://api.github.com/repos/${repoOwner}/${repoName}/keys`;
277
- console.log(url);
278
-
279
- let forceRefresh = false;
280
- while (true) {
281
- const apiKey = await getGitHubApiKey(repoUrl, sshRemote, forceRefresh);
282
- const response = await fetch(url, {
283
- method: "POST",
284
- headers: {
285
- "Authorization": `Bearer ${apiKey}`,
286
- "Accept": "application/vnd.github+json",
287
- "Content-Type": "application/json"
288
- },
289
- body: JSON.stringify({
290
- title: keyTitle,
291
- key: sshPublicKey,
292
- read_only: true
293
- })
294
- });
295
-
296
- if (response.ok) {
297
- console.log("✅ Deploy key added to GitHub repository");
298
- return;
299
- }
300
-
301
- const errorText = await response.text();
302
- if (response.status === 401 && !forceRefresh) {
303
- console.warn(`⚠️ GitHub API rejected credentials (401). Invalidating cached token and re-prompting.`);
304
- try {
305
- fs.unlinkSync(getGitHubKeyCachePath(repoUrl));
306
- } catch {
307
- // Cache file may not exist; ignore
308
- }
309
- forceRefresh = true;
310
- continue;
311
- }
312
- throw new Error(`Failed to add deploy key to GitHub repository: ${response.status} ${errorText}`);
313
- }
314
- }
315
-
316
145
  async function setupRepositoryOnRemote(sshRemote: string, gitURLLive: string, gitRefLive: string): Promise<void> {
317
146
  // Create git folder on remote
318
147
  await runPromise(`ssh ${sshRemote} "mkdir -p ~/machine-alwaysup"`);
@@ -543,6 +372,13 @@ async function main() {
543
372
  await runPromise(`ssh ${sshRemote} "grep -qxF 'set-option -g history-limit 100000' ~/.tmux.conf 2>/dev/null || echo 'set-option -g history-limit 100000' >> ~/.tmux.conf"`);
544
373
  console.log("✅ Tmux scrollback history set to 100000");
545
374
 
375
+ // Before anything clones or installs, so an application that needs credentials on the machine (a private dependency it cannot yarn install without) can put them there first. As early as it can be: git is what the hook has to work with, and it is only installed above.
376
+ await runApplicationHook({
377
+ sshRemote,
378
+ hookName: "machineSetupStart",
379
+ onFailure: "Anything it was meant to set up on the machine is missing, so the clone and install below may fail.",
380
+ });
381
+
546
382
  // 5. Clone current repo into ~/machine-alwaysup with SSH key handling for private repos
547
383
  console.log("Setting up repository...");
548
384
  let gitURLLive = await getGitURLLive();
@@ -574,7 +410,7 @@ async function main() {
574
410
  const keyTitle = `Machine Setup - ${sshRemote} - ${new Date().toISOString()}`;
575
411
 
576
412
  // Add the deploy key to GitHub repository
577
- await addDeployKeyToGitHub(sshPublicKey.trim(), keyTitle, gitURLLive, sshRemote);
413
+ await addDeployKeyToGitHub({ sshPublicKey: sshPublicKey.trim(), keyTitle, repoURL: gitURLLive, sshRemote });
578
414
 
579
415
  // Retry repository setup
580
416
  console.log("Retrying repository setup with SSH key...");
@@ -655,13 +491,18 @@ sudo systemctl start ${SERVICE_UNIT_NAME}
655
491
  console.log(` Screen: ssh ${sshRemote} -t "tmux attach -t ${SERVICE_NAME}"`);
656
492
  console.log(` Status: ssh ${sshRemote} "systemctl status ${SERVICE_UNIT_NAME}"`);
657
493
 
658
- await runMachineDeployHook(sshRemote);
494
+ await runApplicationHook({
495
+ sshRemote,
496
+ hookName: "machineDeploy",
497
+ onFailure: "This is fine if the synchronization database hasn't been set up yet, or isn't running.",
498
+ });
659
499
 
660
500
  console.log("\n🎉 Machine setup complete!");
661
501
  }
662
502
 
663
- async function runMachineDeployHook(sshRemote: string) {
664
- let hookPathBase = path.resolve("./machineDeploy");
503
+ async function runApplicationHook(config: { sshRemote: string; hookName: string; onFailure: string }) {
504
+ let { sshRemote, hookName, onFailure } = config;
505
+ let hookPathBase = path.resolve(`./${hookName}`);
665
506
  let hookExists = false;
666
507
  for (let extension of [".ts", ".tsx", ".js"]) {
667
508
  if (await fsExistsAsync(hookPathBase + extension)) {
@@ -670,20 +511,21 @@ async function runMachineDeployHook(sshRemote: string) {
670
511
  }
671
512
  }
672
513
  if (!hookExists) {
673
- console.log(`No machineDeploy file at ${hookPathBase}, skipping the application deploy hook`);
514
+ console.log(`No ${hookName} file at ${hookPathBase}, skipping that application hook`);
674
515
  return;
675
516
  }
676
- console.log(`Running the machineDeploy hook at ${hookPathBase}`);
517
+ console.log(`Running the ${hookName} hook at ${hookPathBase}`);
677
518
  try {
678
- let hookModule = await import(hookPathBase) as { machineDeploy?: (config: { sshRemote: string }) => Promise<void> };
679
- if (!hookModule.machineDeploy) {
680
- throw new Error(`Expected ${hookPathBase} to export machineDeploy, exports are: ${Object.keys(hookModule).join(", ") || "(none)"}`);
519
+ let hookModule = await import(hookPathBase) as { [exportName: string]: ((config: { sshRemote: string }) => Promise<void>) | undefined };
520
+ let hook = hookModule[hookName];
521
+ if (!hook) {
522
+ throw new Error(`Expected ${hookPathBase} to export ${hookName}, exports are: ${Object.keys(hookModule).join(", ") || "(none)"}`);
681
523
  }
682
- await hookModule.machineDeploy({ sshRemote });
683
- console.log("✅ machineDeploy hook finished");
524
+ await hook({ sshRemote });
525
+ console.log(`✅ ${hookName} hook finished`);
684
526
  } catch (e) {
685
527
  console.error((e as Error).stack || String(e));
686
- console.error(`⚠️ The machineDeploy hook failed (see the error above). This is fine if the synchronization database hasn't been set up yet, or isn't running.`);
528
+ console.error(`⚠️ The ${hookName} hook failed (see the error above). ${onFailure}`);
687
529
  }
688
530
  }
689
531