devvami 1.1.0 → 1.1.2

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.
@@ -1498,5 +1498,5 @@
1498
1498
  ]
1499
1499
  }
1500
1500
  },
1501
- "version": "1.1.0"
1501
+ "version": "1.1.2"
1502
1502
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "devvami",
3
3
  "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -128,6 +128,7 @@
128
128
  "commit": "git-cz",
129
129
  "release": "semantic-release",
130
130
  "release:dry-run": "semantic-release --dry-run",
131
+ "version:sync": "node scripts/sync-version.js",
131
132
  "lint": "eslint src/ tests/",
132
133
  "lint:fix": "eslint src/ tests/ --fix",
133
134
  "format": "prettier --write src/ tests/",
@@ -80,6 +80,8 @@ export default class Init extends Command {
80
80
  let config = await loadConfig()
81
81
 
82
82
  if (!configExists() && !isDryRun && !isJson) {
83
+ // Stop the spinner before interactive prompts to avoid TTY contention on macOS
84
+ configSpinner?.stop()
83
85
  const useOrg = await confirm({ message: 'Do you use a GitHub organization? (y/n)', default: true })
84
86
  let org = ''
85
87
  if (useOrg) {
@@ -68,7 +68,7 @@ export default class Open extends Command {
68
68
  if (isJson) return result
69
69
 
70
70
  await openBrowser(url)
71
- this.log(chalk.green('✓') + ` Opened ${url}`)
71
+ this.log(chalk.green('✓') + ' Opened in browser')
72
72
 
73
73
  return result
74
74
  }
@@ -22,6 +22,11 @@ export default class Upgrade extends Command {
22
22
  const { hasUpdate, current, latest } = await checkForUpdate({ force: true })
23
23
  spinner?.stop()
24
24
 
25
+ // Guard against malformed version strings from the GitHub Releases API
26
+ if (latest && !/^v?\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(latest)) {
27
+ this.error(`Invalid version received from releases API: "${latest}" — update aborted`)
28
+ }
29
+
25
30
  if (!hasUpdate) {
26
31
  const msg = `You're already on the latest version (${current})`
27
32
  if (isJson) return { currentVersion: current, latestVersion: latest, updated: false }
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http'
2
+ import { randomBytes } from 'node:crypto'
2
3
  import { openBrowser } from '../utils/open-browser.js'
3
4
  import { loadConfig } from './config.js'
4
5
 
@@ -45,6 +46,10 @@ export async function storeToken(token) {
45
46
  await keytar.setPassword('devvami', TOKEN_KEY, token)
46
47
  } catch {
47
48
  // Fallback: store in config (less secure)
49
+ process.stderr.write(
50
+ 'Warning: keytar unavailable. ClickUp token will be stored in plaintext.\n' +
51
+ 'Run `dvmi auth logout` after this session on shared machines.\n',
52
+ )
48
53
  const config = await loadConfig()
49
54
  await saveConfig({ ...config, clickup: { ...config.clickup, token } })
50
55
  }
@@ -57,11 +62,20 @@ export async function storeToken(token) {
57
62
  * @returns {Promise<string>} Access token
58
63
  */
59
64
  export async function oauthFlow(clientId, clientSecret) {
65
+ const csrfState = randomBytes(16).toString('hex')
60
66
  return new Promise((resolve, reject) => {
61
67
  const server = http.createServer(async (req, res) => {
62
68
  const url = new URL(req.url ?? '/', 'http://localhost')
63
69
  const code = url.searchParams.get('code')
70
+ const returnedState = url.searchParams.get('state')
64
71
  if (!code) return
72
+ if (!returnedState || returnedState !== csrfState) {
73
+ res.writeHead(400)
74
+ res.end('State mismatch — possible CSRF attack.')
75
+ server.close()
76
+ reject(new Error('OAuth state mismatch — possible CSRF attack'))
77
+ return
78
+ }
65
79
  res.end('Authorization successful! You can close this tab.')
66
80
  server.close()
67
81
  try {
@@ -80,7 +94,7 @@ export async function oauthFlow(clientId, clientSecret) {
80
94
  server.listen(0, async () => {
81
95
  const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
82
96
  const callbackUrl = `http://localhost:${addr.port}/callback`
83
- const authUrl = `https://app.clickup.com/api?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}`
97
+ const authUrl = `https://app.clickup.com/api?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&state=${csrfState}`
84
98
  await openBrowser(authUrl)
85
99
  })
86
100
  server.on('error', reject)
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
1
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { homedir } from 'node:os'
@@ -47,6 +47,7 @@ export async function saveConfig(config, configPath = CONFIG_PATH) {
47
47
  await mkdir(dir, { recursive: true })
48
48
  }
49
49
  await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
50
+ await chmod(configPath, 0o600)
50
51
  }
51
52
 
52
53
  /**
@@ -1,5 +1,5 @@
1
1
  import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
2
- import { join, dirname } from 'node:path'
2
+ import { join, dirname, resolve, sep } from 'node:path'
3
3
  import { execa } from 'execa'
4
4
  import { createOctokit } from './github.js'
5
5
  import { which } from './shell.js'
@@ -204,6 +204,15 @@ export async function fetchPromptByPath(relativePath) {
204
204
  export async function downloadPrompt(relativePath, localDir, opts = {}) {
205
205
  const destPath = join(localDir, relativePath)
206
206
 
207
+ // Prevent path traversal: destPath must remain within localDir
208
+ const safeBase = resolve(localDir) + sep
209
+ if (!resolve(destPath).startsWith(safeBase)) {
210
+ throw new DvmiError(
211
+ `Invalid prompt path: "${relativePath}"`,
212
+ 'Path must stay within the prompts directory',
213
+ )
214
+ }
215
+
207
216
  // Fast-path: skip without a network round-trip if file exists and no overwrite
208
217
  if (!opts.overwrite) {
209
218
  try {
@@ -245,6 +254,16 @@ export async function downloadPrompt(relativePath, localDir, opts = {}) {
245
254
  */
246
255
  export async function resolveLocalPrompt(relativePath, localDir) {
247
256
  const fullPath = join(localDir, relativePath)
257
+
258
+ // Prevent path traversal: fullPath must remain within localDir
259
+ const safeBase = resolve(localDir) + sep
260
+ if (!resolve(fullPath).startsWith(safeBase)) {
261
+ throw new DvmiError(
262
+ `Invalid prompt path: "${relativePath}"`,
263
+ 'Path must stay within the prompts directory',
264
+ )
265
+ }
266
+
248
267
  let raw
249
268
  try {
250
269
  raw = await readFile(fullPath, 'utf8')
@@ -2,7 +2,10 @@ import { execa } from 'execa'
2
2
  import { which, exec } from './shell.js'
3
3
  import { DvmiError } from '../utils/errors.js'
4
4
 
5
- /** GitHub spec-kit package source for uv */
5
+ /** GitHub spec-kit package source for uv.
6
+ * TODO: pin to a specific tagged release (e.g. #v1.x.x) once one is available upstream.
7
+ * Tracking: https://github.com/github/spec-kit/releases
8
+ */
6
9
  const SPECKIT_FROM = 'git+https://github.com/github/spec-kit.git'
7
10
 
8
11
  /**