@neverquits/nq 0.1.1 → 0.1.3
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/bin/nq.mjs +9 -0
- package/lib/commands/credential-helper.mjs +73 -0
- package/lib/commands/pull.mjs +69 -19
- package/package.json +1 -1
package/bin/nq.mjs
CHANGED
|
@@ -102,6 +102,15 @@ async function main() {
|
|
|
102
102
|
return
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Invoked by git as a credential helper from inside a workspace clone.
|
|
106
|
+
// See cli/lib/commands/credential-helper.mjs for the details.
|
|
107
|
+
if (command === 'credential-helper') {
|
|
108
|
+
const action = args[1] ?? 'get'
|
|
109
|
+
const { credentialHelper } = await import('../lib/commands/credential-helper.mjs')
|
|
110
|
+
await credentialHelper(action)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
console.error(` Unknown command: ${command}`)
|
|
106
115
|
console.error(' Run "nq help" for usage.')
|
|
107
116
|
process.exit(1)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Git credential helper. Configured per-repo by `nq pull` so that every
|
|
2
|
+
// `git push` / `git fetch` to github.com transparently mints a fresh
|
|
3
|
+
// installation token via the nq API.
|
|
4
|
+
//
|
|
5
|
+
// Git invokes us with one of {get, store, erase} and pipes a set of
|
|
6
|
+
// key=value lines on stdin (host, protocol, etc.). We only respond to
|
|
7
|
+
// `get`; the others are no-ops since we don't persist anything.
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'node:child_process'
|
|
10
|
+
import { apiFetch } from '../api.mjs'
|
|
11
|
+
|
|
12
|
+
export async function credentialHelper(action) {
|
|
13
|
+
if (action !== 'get') {
|
|
14
|
+
// store/erase: nothing to do. Drain stdin to keep git happy.
|
|
15
|
+
await readStdin()
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const input = await readStdin()
|
|
20
|
+
const fields = parseFields(input)
|
|
21
|
+
if (fields.host !== 'github.com') return
|
|
22
|
+
|
|
23
|
+
// The helper is configured per-repo by `nq pull`, which stashes the
|
|
24
|
+
// identifiers in `.git/config` under the `nq.` namespace.
|
|
25
|
+
const orgId = readGitConfig('nq.orgId')
|
|
26
|
+
const agentKey = readGitConfig('nq.agentKey')
|
|
27
|
+
if (!orgId || !agentKey) {
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
' nq credential-helper: missing nq.orgId / nq.agentKey in .git/config. Re-run `nq pull`.\n',
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const res = await apiFetch('/api/workspace/install-token', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
body: JSON.stringify({ orgId, agentKey }),
|
|
37
|
+
})
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
` nq credential-helper: install-token request failed (${res.status}).\n`,
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const { token, username } = await res.json()
|
|
45
|
+
process.stdout.write(`username=${username ?? 'x-access-token'}\n`)
|
|
46
|
+
process.stdout.write(`password=${token}\n`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseFields(text) {
|
|
50
|
+
const out = {}
|
|
51
|
+
for (const line of text.split('\n')) {
|
|
52
|
+
const idx = line.indexOf('=')
|
|
53
|
+
if (idx > 0) out[line.slice(0, idx)] = line.slice(idx + 1)
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readStdin() {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let buf = ''
|
|
61
|
+
process.stdin.setEncoding('utf8')
|
|
62
|
+
process.stdin.on('data', (chunk) => (buf += chunk))
|
|
63
|
+
process.stdin.on('end', () => resolve(buf))
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readGitConfig(key) {
|
|
68
|
+
try {
|
|
69
|
+
return execSync(`git config --local ${key}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
package/lib/commands/pull.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process'
|
|
2
|
-
import { existsSync, writeFileSync
|
|
3
|
-
import { join, resolve } from 'node:path'
|
|
2
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, resolve, dirname } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { apiFetch, getToken } from '../api.mjs'
|
|
5
6
|
import { getApiUrl } from '../config.mjs'
|
|
6
7
|
|
|
8
|
+
const NQ_BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'nq.mjs')
|
|
9
|
+
const NODE_BIN = process.execPath
|
|
10
|
+
|
|
7
11
|
export async function pull(agentKey, options) {
|
|
8
12
|
const orgId = options.org
|
|
9
13
|
if (!orgId) {
|
|
@@ -18,27 +22,28 @@ export async function pull(agentKey, options) {
|
|
|
18
22
|
console.log()
|
|
19
23
|
console.log(` Pulling workspace: ${orgId}/${agentKey}`)
|
|
20
24
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// Ask the backend for a fresh installation token + the repo identifier.
|
|
26
|
+
// The token is short-lived; we bake it into the clone URL once and then
|
|
27
|
+
// configure a credential helper so future pushes/pulls mint a new one.
|
|
28
|
+
const tokenRes = await apiFetch('/api/workspace/install-token', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify({ orgId, agentKey }),
|
|
31
|
+
})
|
|
32
|
+
if (tokenRes.status === 404) {
|
|
33
|
+
console.error(' No workspace repo found. Has onboarding been completed for this org?')
|
|
26
34
|
process.exit(1)
|
|
27
35
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (!repo?.repoName) {
|
|
32
|
-
console.error(' No workspace repo found. Has onboarding been completed for this org?')
|
|
36
|
+
if (!tokenRes.ok) {
|
|
37
|
+
console.error(` Failed to mint install token: ${tokenRes.status}`)
|
|
33
38
|
process.exit(1)
|
|
34
39
|
}
|
|
40
|
+
const { token, username, githubOrg, repoName } = await tokenRes.json()
|
|
35
41
|
|
|
36
|
-
// 2. Clone or pull
|
|
37
42
|
const gitDir = join(dest, '.git')
|
|
38
|
-
const org = repo.githubOrg ?? process.env.GITHUB_WORKSPACES_ORG
|
|
39
43
|
|
|
40
44
|
if (existsSync(gitDir)) {
|
|
41
45
|
console.log(' Pulling latest changes...')
|
|
46
|
+
refreshRemote(dest, username, token, githubOrg, repoName)
|
|
42
47
|
try {
|
|
43
48
|
execSync('git pull --ff-only', { cwd: dest, stdio: 'inherit', timeout: 30_000 })
|
|
44
49
|
} catch {
|
|
@@ -46,20 +51,24 @@ export async function pull(agentKey, options) {
|
|
|
46
51
|
process.exit(1)
|
|
47
52
|
}
|
|
48
53
|
} else {
|
|
49
|
-
console.log(` Cloning ${
|
|
54
|
+
console.log(` Cloning ${githubOrg}/${repoName}...`)
|
|
55
|
+
const cloneUrl = buildAuthedUrl(username, token, githubOrg, repoName)
|
|
50
56
|
try {
|
|
51
|
-
execSync(`git clone
|
|
57
|
+
execSync(`git clone ${shArg(cloneUrl)} ${shArg(dest)}`, {
|
|
52
58
|
stdio: 'inherit',
|
|
53
59
|
timeout: 60_000,
|
|
54
60
|
})
|
|
55
61
|
} catch {
|
|
56
|
-
console.error(' Clone failed.
|
|
57
|
-
console.error(` Try: gh auth login && gh repo clone ${org}/${repo.repoName}`)
|
|
62
|
+
console.error(' Clone failed.')
|
|
58
63
|
process.exit(1)
|
|
59
64
|
}
|
|
65
|
+
// Strip the embedded token from the recorded remote so it's not
|
|
66
|
+
// sitting in plaintext in .git/config indefinitely. We'll rewrite
|
|
67
|
+
// it with a fresh token on the next pull/push via the helper.
|
|
68
|
+
setRemote(dest, `https://github.com/${githubOrg}/${repoName}.git`)
|
|
69
|
+
configureCredentialHelper(dest, orgId, agentKey)
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
// 3. Write .mcp.json if we have a session
|
|
63
72
|
if (sessionId) {
|
|
64
73
|
writeMcpJson(dest, orgId, agentKey, sessionId)
|
|
65
74
|
console.log(' Wrote .mcp.json')
|
|
@@ -70,6 +79,7 @@ export async function pull(agentKey, options) {
|
|
|
70
79
|
|
|
71
80
|
console.log()
|
|
72
81
|
console.log(` Done. Workspace at: ${dest}`)
|
|
82
|
+
console.log(' You can `git push` from here at any time — no GitHub login needed.')
|
|
73
83
|
console.log()
|
|
74
84
|
|
|
75
85
|
if (sessionId) {
|
|
@@ -78,6 +88,46 @@ export async function pull(agentKey, options) {
|
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
function buildAuthedUrl(username, token, githubOrg, repoName) {
|
|
92
|
+
const user = encodeURIComponent(username ?? 'x-access-token')
|
|
93
|
+
const pass = encodeURIComponent(token)
|
|
94
|
+
return `https://${user}:${pass}@github.com/${githubOrg}/${repoName}.git`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function refreshRemote(dest, username, token, githubOrg, repoName) {
|
|
98
|
+
const url = buildAuthedUrl(username, token, githubOrg, repoName)
|
|
99
|
+
execSync(`git remote set-url origin ${shArg(url)}`, { cwd: dest, stdio: 'ignore' })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setRemote(dest, url) {
|
|
103
|
+
execSync(`git remote set-url origin ${shArg(url)}`, { cwd: dest, stdio: 'ignore' })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function configureCredentialHelper(dest, orgId, agentKey) {
|
|
107
|
+
// The helper is invoked by git during a github.com auth challenge.
|
|
108
|
+
// We point at the *absolute* path of this nq install (node binary +
|
|
109
|
+
// bin/nq.mjs) so the helper works regardless of whether `nq` is on
|
|
110
|
+
// the user's PATH — e.g. when they invoked us via `npx`.
|
|
111
|
+
const helperCmd = `${shArg(NODE_BIN)} ${shArg(NQ_BIN)} credential-helper`
|
|
112
|
+
const cmds = [
|
|
113
|
+
['nq.orgId', orgId],
|
|
114
|
+
['nq.agentKey', agentKey],
|
|
115
|
+
// Per-host helper, applied only inside this repo via --local config.
|
|
116
|
+
// The '!' prefix tells git to run it as a shell command.
|
|
117
|
+
['credential.https://github.com.helper', `!${helperCmd}`],
|
|
118
|
+
]
|
|
119
|
+
for (const [k, v] of cmds) {
|
|
120
|
+
execSync(`git config --local ${shArg(k)} ${shArg(v)}`, {
|
|
121
|
+
cwd: dest,
|
|
122
|
+
stdio: 'ignore',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function shArg(s) {
|
|
128
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
129
|
+
}
|
|
130
|
+
|
|
81
131
|
function writeMcpJson(dest, orgId, agentKey, sessionId) {
|
|
82
132
|
const apiUrl = getApiUrl()
|
|
83
133
|
const token = getToken()
|