@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 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
+ }
@@ -1,9 +1,13 @@
1
1
  import { execSync } from 'node:child_process'
2
- import { existsSync, writeFileSync, mkdirSync } from 'node:fs'
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
- // 1. Check if workspace repo exists
22
- const res = await apiFetch(`/api/workspace/repos?orgId=${orgId}&agentKey=${agentKey}`)
23
-
24
- if (!res.ok) {
25
- console.error(` Failed to fetch workspace info: ${res.status}`)
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
- const repo = await res.json()
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 ${org}/${repo.repoName}...`)
54
+ console.log(` Cloning ${githubOrg}/${repoName}...`)
55
+ const cloneUrl = buildAuthedUrl(username, token, githubOrg, repoName)
50
56
  try {
51
- execSync(`git clone https://github.com/${org}/${repo.repoName}.git ${dest}`, {
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. Make sure you have git access to the repo.')
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neverquits/nq",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "NeverQuits CLI — workspace setup for AI agent operators",
5
5
  "type": "module",
6
6
  "bin": {