querysub 0.674.0 → 0.676.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.674.0",
3
+ "version": "0.676.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",
@@ -79,8 +79,8 @@
79
79
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
80
80
  "pako": "^2.1.0",
81
81
  "peggy": "^5.0.6",
82
- "sliftutils": "^1.7.140",
83
- "socket-function": "^1.2.35",
82
+ "sliftutils": "^1.7.149",
83
+ "socket-function": "^1.2.36",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
86
86
  "typesafecss": "^0.32.0",
@@ -138,7 +138,9 @@ export class ServiceDetailPage extends qreact.Component {
138
138
  {group.map(({ entryIndex, index, target }) => {
139
139
  // Variables set on the entry but no longer in the command are still shown (flagged), so stale values can be cleared.
140
140
  let variableNames = [...new Set([...templateVariables, ...Object.keys(target.variables)])];
141
- return <div key={entryIndex} className={css.hbox(8).alignItems("center").pad2(8, 4).hsl(0, 0, 100)}>
141
+ return <div key={entryIndex} className={css.hbox(8).alignItems("center").pad2(8, 4).hsl(0, 0, 100)}
142
+ title={applyCommandTemplate(config.parameters.command, target.variables)}
143
+ >
142
144
  <div className={css.fontSize(11).colorhsl(0, 0, 40)}>{getScreenName({ serviceKey: config.parameters.key, index })}</div>
143
145
  {variableNames.map(name => {
144
146
  let inCommand = templateVariables.includes(name);
@@ -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"`);
@@ -574,7 +403,7 @@ async function main() {
574
403
  const keyTitle = `Machine Setup - ${sshRemote} - ${new Date().toISOString()}`;
575
404
 
576
405
  // Add the deploy key to GitHub repository
577
- await addDeployKeyToGitHub(sshPublicKey.trim(), keyTitle, gitURLLive, sshRemote);
406
+ await addDeployKeyToGitHub({ sshPublicKey: sshPublicKey.trim(), keyTitle, repoURL: gitURLLive, sshRemote });
578
407
 
579
408
  // Retry repository setup
580
409
  console.log("Retrying repository setup with SSH key...");