devvami 1.0.0 → 1.1.1

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.
@@ -0,0 +1,189 @@
1
+ import { Command, Args, Flags } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import chalk from 'chalk'
4
+ import { select } from '@inquirer/prompts'
5
+ import { join } from 'node:path'
6
+ import { readdir } from 'node:fs/promises'
7
+ import { resolveLocalPrompt, invokeTool, SUPPORTED_TOOLS } from '../../services/prompts.js'
8
+ import { loadConfig } from '../../services/config.js'
9
+ import { DvmiError } from '../../utils/errors.js'
10
+
11
+ /** @import { AITool } from '../../types.js' */
12
+
13
+ const DEFAULT_PROMPTS_DIR = '.prompts'
14
+
15
+ /**
16
+ * Walk a directory recursively and collect `.md` file paths relative to `base`.
17
+ * @param {string} dir
18
+ * @param {string} base
19
+ * @returns {Promise<string[]>}
20
+ */
21
+ async function walkPrompts(dir, base) {
22
+ /** @type {string[]} */
23
+ const results = []
24
+ let entries
25
+ try {
26
+ entries = await readdir(dir, { withFileTypes: true })
27
+ } catch {
28
+ return results
29
+ }
30
+ for (const entry of entries) {
31
+ const full = join(dir, entry.name)
32
+ if (entry.isDirectory()) {
33
+ const sub = await walkPrompts(full, base)
34
+ results.push(...sub)
35
+ } else if (entry.name.endsWith('.md')) {
36
+ results.push(full.replace(base + '/', ''))
37
+ }
38
+ }
39
+ return results
40
+ }
41
+
42
+ export default class PromptsRun extends Command {
43
+ static description = 'Execute a local prompt with a configured AI tool'
44
+
45
+ static examples = [
46
+ '<%= config.bin %> prompts run',
47
+ '<%= config.bin %> prompts run coding/refactor-prompt.md',
48
+ '<%= config.bin %> prompts run coding/refactor-prompt.md --tool opencode',
49
+ '<%= config.bin %> prompts run coding/refactor-prompt.md --json',
50
+ ]
51
+
52
+ static enableJsonFlag = true
53
+
54
+ static args = {
55
+ path: Args.string({
56
+ description: 'Relative path of the local prompt (e.g. coding/refactor-prompt.md)',
57
+ required: false,
58
+ }),
59
+ }
60
+
61
+ static flags = {
62
+ tool: Flags.string({
63
+ char: 't',
64
+ description: `AI tool to use (${Object.keys(SUPPORTED_TOOLS).join(', ')})`,
65
+ options: Object.keys(SUPPORTED_TOOLS),
66
+ }),
67
+ }
68
+
69
+ async run() {
70
+ const { args, flags } = await this.parse(PromptsRun)
71
+ const isJson = flags.json
72
+
73
+ // Load config
74
+ let config = {}
75
+ try {
76
+ config = await loadConfig()
77
+ } catch {
78
+ /* use defaults */
79
+ }
80
+
81
+ const localDir =
82
+ process.env.DVMI_PROMPTS_DIR ?? config.promptsDir ?? join(process.cwd(), DEFAULT_PROMPTS_DIR)
83
+
84
+ // Resolve tool: --tool flag > config.aiTool
85
+ const toolName = /** @type {AITool | undefined} */ (flags.tool ?? config.aiTool)
86
+
87
+ // In --json mode, output the invocation plan without spawning
88
+ if (isJson) {
89
+ if (!args.path) {
90
+ this.error('Prompt path is required in --json mode', {
91
+ exit: 1,
92
+ suggestions: ['Run `dvmi prompts run <path> --json`'],
93
+ })
94
+ }
95
+
96
+ if (!toolName) {
97
+ this.error('No AI tool configured', {
98
+ exit: 1,
99
+ suggestions: ['Run `dvmi init` to configure your preferred AI tool, or pass --tool <name>'],
100
+ })
101
+ }
102
+
103
+ if (!SUPPORTED_TOOLS[toolName]) {
104
+ this.error(`Unknown tool: "${toolName}". Supported: ${Object.keys(SUPPORTED_TOOLS).join(', ')}`, {
105
+ exit: 1,
106
+ })
107
+ }
108
+
109
+ let prompt
110
+ try {
111
+ prompt = await resolveLocalPrompt(args.path, localDir)
112
+ } catch (err) {
113
+ if (err instanceof DvmiError) {
114
+ this.error(err.message, { exit: err.exitCode, suggestions: [err.hint] })
115
+ }
116
+ throw err
117
+ }
118
+
119
+ const tool = SUPPORTED_TOOLS[toolName]
120
+ const invocation = [tool.bin.join(' '), tool.promptFlag, '<prompt content>'].join(' ')
121
+ return {
122
+ tool: toolName,
123
+ promptPath: args.path,
124
+ invocation,
125
+ preview: prompt.body.slice(0, 200),
126
+ }
127
+ }
128
+
129
+ // --- Interactive mode ---
130
+
131
+ // Resolve path interactively if not provided
132
+ let relativePath = args.path
133
+ if (!relativePath) {
134
+ const localPaths = await walkPrompts(localDir, localDir)
135
+
136
+ if (localPaths.length === 0) {
137
+ this.error('No local prompts found', {
138
+ exit: 1,
139
+ suggestions: [`Run \`dvmi prompts download\` to download prompts to ${localDir}`],
140
+ })
141
+ }
142
+
143
+ relativePath = await select({
144
+ message: 'Select a local prompt to run:',
145
+ choices: localPaths.map((p) => ({ name: p, value: p })),
146
+ })
147
+ }
148
+
149
+ // Verify tool is configured
150
+ if (!toolName) {
151
+ this.error('No AI tool configured', {
152
+ exit: 1,
153
+ suggestions: ['Run `dvmi init` to configure your preferred AI tool, or pass --tool <name>'],
154
+ })
155
+ }
156
+
157
+ // Load prompt
158
+ const spinner = ora({
159
+ spinner: 'arc',
160
+ color: false,
161
+ text: chalk.hex('#FF6B2B')('Loading prompt...'),
162
+ }).start()
163
+
164
+ let prompt
165
+ try {
166
+ prompt = await resolveLocalPrompt(relativePath, localDir)
167
+ spinner.stop()
168
+ } catch (err) {
169
+ spinner.fail()
170
+ if (err instanceof DvmiError) {
171
+ this.error(err.message, { exit: err.exitCode, suggestions: [err.hint] })
172
+ }
173
+ throw err
174
+ }
175
+
176
+ this.log(chalk.bold(`\nRunning: ${chalk.hex('#FF9A5C')(prompt.title)}`))
177
+ this.log(chalk.dim(` Tool: ${toolName}`) + '\n')
178
+
179
+ // Invoke tool
180
+ try {
181
+ await invokeTool(toolName, prompt.body)
182
+ } catch (err) {
183
+ if (err instanceof DvmiError) {
184
+ this.error(err.message, { exit: err.exitCode, suggestions: [err.hint] })
185
+ }
186
+ throw err
187
+ }
188
+ }
189
+ }
@@ -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 }
@@ -0,0 +1,159 @@
1
+ import chalk from 'chalk'
2
+ import { marked } from 'marked'
3
+ import { renderTable } from './table.js'
4
+
5
+ /** @import { Prompt, Skill, AwesomeEntry } from '../types.js' */
6
+
7
+ /**
8
+ * Format a list of prompts as a terminal table.
9
+ * Columns: title, category, description.
10
+ *
11
+ * @param {Prompt[]} prompts
12
+ * @returns {string}
13
+ */
14
+ export function formatPromptTable(prompts) {
15
+ if (prompts.length === 0) {
16
+ return chalk.dim('No prompts found.')
17
+ }
18
+
19
+ return renderTable(
20
+ /** @type {Record<string, unknown>[]} */ (prompts),
21
+ [
22
+ {
23
+ header: 'Title',
24
+ key: 'title',
25
+ width: 36,
26
+ colorize: (v) => chalk.hex('#FF9A5C')(v),
27
+ },
28
+ {
29
+ header: 'Category',
30
+ key: 'category',
31
+ width: 16,
32
+ format: (v) => v ?? '—',
33
+ colorize: (v) => chalk.hex('#4A9EFF')(v),
34
+ },
35
+ {
36
+ header: 'Description',
37
+ key: 'description',
38
+ width: 60,
39
+ format: (v) => v ?? '—',
40
+ colorize: (v) => chalk.white(v),
41
+ },
42
+ ],
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Format a single prompt's full content for display in the terminal.
48
+ * Renders the title as a header and the body as plain text (markdown stripped).
49
+ *
50
+ * @param {Prompt} prompt
51
+ * @returns {string}
52
+ */
53
+ export function formatPromptBody(prompt) {
54
+ const titleLine = chalk.bold.hex('#FF6B2B')(prompt.title)
55
+ const divider = chalk.dim('─'.repeat(60))
56
+
57
+ const meta = [
58
+ prompt.category ? chalk.dim(`Category: `) + chalk.hex('#4A9EFF')(prompt.category) : null,
59
+ prompt.description ? chalk.dim(`Description: `) + chalk.white(prompt.description) : null,
60
+ prompt.tags?.length ? chalk.dim(`Tags: `) + chalk.hex('#4A9EFF')(prompt.tags.join(', ')) : null,
61
+ ]
62
+ .filter(Boolean)
63
+ .join('\n')
64
+
65
+ // Render markdown to plain terminal text by stripping HTML tags from marked output
66
+ const rendered = marked(prompt.body, { async: false })
67
+ const plain = String(rendered)
68
+ .replace(/<[^>]+>/g, '')
69
+ .replace(/&amp;/g, '&')
70
+ .replace(/&lt;/g, '<')
71
+ .replace(/&gt;/g, '>')
72
+ .replace(/&quot;/g, '"')
73
+ .trim()
74
+
75
+ const parts = [titleLine, divider]
76
+ if (meta) parts.push(meta, divider)
77
+ parts.push(plain)
78
+
79
+ return parts.join('\n')
80
+ }
81
+
82
+ /**
83
+ * Format a list of skills.sh skills as a terminal table.
84
+ * Columns: name, installs, description.
85
+ *
86
+ * @param {Skill[]} skills
87
+ * @returns {string}
88
+ */
89
+ export function formatSkillTable(skills) {
90
+ if (skills.length === 0) {
91
+ return chalk.dim('No skills found.')
92
+ }
93
+
94
+ return renderTable(
95
+ /** @type {Record<string, unknown>[]} */ (skills),
96
+ [
97
+ {
98
+ header: 'Name',
99
+ key: 'name',
100
+ width: 36,
101
+ colorize: (v) => chalk.hex('#FF9A5C')(v),
102
+ },
103
+ {
104
+ header: 'Installs',
105
+ key: 'installs',
106
+ width: 10,
107
+ format: (v) => (v != null ? String(v) : '—'),
108
+ colorize: (v) => chalk.hex('#4A9EFF')(v),
109
+ },
110
+ {
111
+ header: 'Description',
112
+ key: 'description',
113
+ width: 60,
114
+ format: (v) => v ?? '—',
115
+ colorize: (v) => chalk.white(v),
116
+ },
117
+ ],
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Format a list of awesome-copilot entries as a terminal table.
123
+ * Columns: name, category, description.
124
+ *
125
+ * @param {AwesomeEntry[]} entries
126
+ * @param {string} [category] - Active category label shown in the header
127
+ * @returns {string}
128
+ */
129
+ export function formatAwesomeTable(entries, category) {
130
+ if (entries.length === 0) {
131
+ return chalk.dim(category ? `No entries found for category "${category}".` : 'No entries found.')
132
+ }
133
+
134
+ return renderTable(
135
+ /** @type {Record<string, unknown>[]} */ (entries),
136
+ [
137
+ {
138
+ header: 'Name',
139
+ key: 'name',
140
+ width: 36,
141
+ colorize: (v) => chalk.hex('#FF9A5C')(v),
142
+ },
143
+ {
144
+ header: 'Category',
145
+ key: 'category',
146
+ width: 14,
147
+ format: (v) => v ?? '—',
148
+ colorize: (v) => chalk.hex('#4A9EFF')(v),
149
+ },
150
+ {
151
+ header: 'Description',
152
+ key: 'description',
153
+ width: 58,
154
+ format: (v) => v ?? '—',
155
+ colorize: (v) => chalk.white(v),
156
+ },
157
+ ],
158
+ )
159
+ }
package/src/help.js CHANGED
@@ -68,6 +68,16 @@ const CATEGORIES = [
68
68
  { id: 'costs:get', hint: '[--period] [--profile]' },
69
69
  ],
70
70
  },
71
+ {
72
+ title: 'AI Prompts',
73
+ cmds: [
74
+ { id: 'prompts:list', hint: '[--filter]' },
75
+ { id: 'prompts:download', hint: '<PATH> [--overwrite]' },
76
+ { id: 'prompts:browse', hint: '[--source] [--query] [--category]' },
77
+ { id: 'prompts:install-speckit', hint: '[--force]' },
78
+ { id: 'prompts:run', hint: '[PATH] [--tool]' },
79
+ ],
80
+ },
71
81
  {
72
82
  title: 'Setup & Ambiente',
73
83
  cmds: [
@@ -82,14 +92,19 @@ const CATEGORIES = [
82
92
 
83
93
  // ─── Example commands shown at bottom of root help ──────────────────────────
84
94
  const EXAMPLES = [
85
- { cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
86
- { cmd: 'dvmi docs read openapi.yaml', note: 'Tabella endpoints OpenAPI nel terminale' },
87
- { cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
88
- { cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
89
- { cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
90
- { cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
91
- { cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
92
- { cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
95
+ { cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
96
+ { cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
97
+ { cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
98
+ { cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
99
+ { cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
100
+ { cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
101
+ { cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
102
+ { cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
103
+ { cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
104
+ { cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
105
+ { cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
106
+ { cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
107
+ { cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
93
108
  ]
94
109
 
95
110
  // ─── Help class ─────────────────────────────────────────────────────────────
@@ -0,0 +1,123 @@
1
+ import { createOctokit } from './github.js'
2
+ import { DvmiError } from '../utils/errors.js'
3
+
4
+ /** @import { AwesomeEntry } from '../types.js' */
5
+
6
+ /**
7
+ * Supported categories in github/awesome-copilot.
8
+ * Each maps to `docs/README.<category>.md` in the repository.
9
+ * @type {string[]}
10
+ */
11
+ export const AWESOME_CATEGORIES = ['agents', 'instructions', 'skills', 'plugins', 'hooks', 'workflows']
12
+
13
+ const AWESOME_REPO = { owner: 'github', repo: 'awesome-copilot' }
14
+
15
+ /**
16
+ * Parse a GitHub-flavoured markdown table into AwesomeEntry objects.
17
+ *
18
+ * Expects at least two columns: `| Name/Link | Description |`
19
+ * The first column may contain `[text](url)` markdown links; badge images
20
+ * (e.g. `[![foo](img)](url)`) are stripped so only the text survives.
21
+ *
22
+ * @param {string} md - Raw markdown content of the file
23
+ * @param {string} category - Category label attached to every returned entry
24
+ * @returns {AwesomeEntry[]}
25
+ */
26
+ export function parseMarkdownTable(md, category) {
27
+ /** @type {AwesomeEntry[]} */
28
+ const entries = []
29
+
30
+ for (const line of md.split('\n')) {
31
+ // Only process table data rows (start + end with |, not separator rows)
32
+ const trimmed = line.trim()
33
+ if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue
34
+ if (/^\|[\s\-:|]+\|/.test(trimmed)) continue // header separator
35
+
36
+ const cells = trimmed
37
+ .slice(1, -1) // remove leading and trailing |
38
+ .split('|')
39
+ .map((c) => c.trim())
40
+
41
+ if (cells.length < 2) continue
42
+
43
+ const rawName = cells[0]
44
+ const description = cells[1] ?? ''
45
+
46
+ // Skip header row (first column is literally "Name" or similar)
47
+ if (/^[\*_]?name[\*_]?$/i.test(rawName)) continue
48
+
49
+ // Strip badge images: [![alt](img)](url) → keep nothing; [![alt](img)] → keep nothing
50
+ const noBadge = rawName.replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)/g, '').replace(/!\[.*?\]\(.*?\)/g, '').trim()
51
+
52
+ // Extract [text](url) link
53
+ const linkMatch = noBadge.match(/\[([^\]]+)\]\(([^)]+)\)/)
54
+ const name = linkMatch ? linkMatch[1].trim() : noBadge.replace(/\[|\]/g, '').trim()
55
+ const url = linkMatch ? linkMatch[2].trim() : ''
56
+
57
+ if (!name) continue
58
+
59
+ entries.push(
60
+ /** @type {AwesomeEntry} */ ({
61
+ name,
62
+ description: description.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim(),
63
+ url,
64
+ category,
65
+ source: 'awesome-copilot',
66
+ }),
67
+ )
68
+ }
69
+
70
+ return entries
71
+ }
72
+
73
+ /**
74
+ * Fetch awesome-copilot entries for a given category from GitHub.
75
+ *
76
+ * Retrieves `docs/README.<category>.md` from the `github/awesome-copilot`
77
+ * repository and parses the markdown table into AwesomeEntry objects.
78
+ *
79
+ * @param {string} category - One of {@link AWESOME_CATEGORIES}
80
+ * @returns {Promise<AwesomeEntry[]>}
81
+ * @throws {DvmiError} when category is invalid, file not found, or auth is missing
82
+ */
83
+ export async function fetchAwesomeEntries(category) {
84
+ if (!AWESOME_CATEGORIES.includes(category)) {
85
+ throw new DvmiError(
86
+ `Unknown awesome-copilot category: "${category}"`,
87
+ `Valid categories: ${AWESOME_CATEGORIES.join(', ')}`,
88
+ )
89
+ }
90
+
91
+ const octokit = await createOctokit()
92
+ const path = `docs/README.${category}.md`
93
+
94
+ let data
95
+ try {
96
+ const res = await octokit.rest.repos.getContent({
97
+ owner: AWESOME_REPO.owner,
98
+ repo: AWESOME_REPO.repo,
99
+ path,
100
+ })
101
+ data = res.data
102
+ } catch (err) {
103
+ const status = /** @type {{ status?: number }} */ (err).status
104
+ if (status === 404) {
105
+ throw new DvmiError(
106
+ `Category file not found: ${path}`,
107
+ `Check available categories: ${AWESOME_CATEGORIES.join(', ')}`,
108
+ )
109
+ }
110
+ throw err
111
+ }
112
+
113
+ const file = /** @type {{ content?: string, encoding?: string }} */ (data)
114
+ if (!file.content || file.encoding !== 'base64') {
115
+ throw new DvmiError(
116
+ `Unable to read awesome-copilot category: ${category}`,
117
+ 'The file may be a directory or have an unsupported encoding',
118
+ )
119
+ }
120
+
121
+ const md = Buffer.from(file.content.replace(/\n/g, ''), 'base64').toString('utf8')
122
+ return parseMarkdownTable(md, category)
123
+ }
@@ -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
  /**
@@ -20,7 +20,8 @@ async function getToken() {
20
20
  */
21
21
  export async function createOctokit() {
22
22
  const token = await getToken()
23
- return new Octokit({ auth: token })
23
+ const baseUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com'
24
+ return new Octokit({ auth: token, baseUrl })
24
25
  }
25
26
 
26
27
  /**