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
|
@@ -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
|
|
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
|
|
664
|
-
let
|
|
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
|
|
514
|
+
console.log(`No ${hookName} file at ${hookPathBase}, skipping that application hook`);
|
|
674
515
|
return;
|
|
675
516
|
}
|
|
676
|
-
console.log(`Running the
|
|
517
|
+
console.log(`Running the ${hookName} hook at ${hookPathBase}`);
|
|
677
518
|
try {
|
|
678
|
-
let hookModule = await import(hookPathBase) as {
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
683
|
-
console.log(
|
|
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
|
|
528
|
+
console.error(`⚠️ The ${hookName} hook failed (see the error above). ${onFailure}`);
|
|
687
529
|
}
|
|
688
530
|
}
|
|
689
531
|
|