@1endpoint/coding-helper 0.1.2 → 0.1.4
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/README.md +24 -7
- package/package.json +1 -1
- package/src/browser.mjs +40 -0
- package/src/cli.mjs +27 -11
- package/src/credentials.mjs +158 -0
- package/src/ui.mjs +145 -18
package/README.md
CHANGED
|
@@ -19,8 +19,9 @@ npx @1endpoint/coding-helper
|
|
|
19
19
|
1. Choose **English** or **Tiếng Việt**.
|
|
20
20
|
2. Choose **Setup**.
|
|
21
21
|
3. Select **DSH** or **Claude Code**.
|
|
22
|
-
4.
|
|
23
|
-
5.
|
|
22
|
+
4. Paste your API key, or select **Open Console** and press Enter to create a key first.
|
|
23
|
+
5. Select models from the 1endpoint catalog.
|
|
24
|
+
6. Review the selection, then choose **Confirm**. The key is saved automatically for the selected tool.
|
|
24
25
|
|
|
25
26
|
Use **↑ / ↓** to move and **Enter** to select. Press **Escape** to cancel. Confirmation is a selection, not a typed `y/n` answer.
|
|
26
27
|
|
|
@@ -34,9 +35,19 @@ If the catalog cannot be loaded, choose **Retry**, explicitly choose **Manual mo
|
|
|
34
35
|
|
|
35
36
|
## 2. Provide your API key
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
Paste your key directly into the helper's **masked API-key prompt**. The key is not shown on screen or placed in command arguments.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Need a key? Select **Open Console** and press **Enter**. The helper opens [API keys](https://console.1endpoint.dev/api-keys), or the [Vietnamese API-key page](https://console.1endpoint.dev/vi/api-keys) when Vietnamese is selected. Create a key, copy it, return to the terminal and paste it into the prompt.
|
|
41
|
+
|
|
42
|
+
The key is saved automatically for the selected tool when setup or reconfiguration is confirmed. There is no storage-choice prompt. A key entered while starting a tool is also saved for future launches.
|
|
43
|
+
|
|
44
|
+
The saved file is plaintext, **not encrypted**, protected with private file permissions (0600 file, 0700 directory on POSIX systems). Other processes running as your user may still access it. Keys are never displayed. If a key is already available, select **Use an existing key** to reuse it.
|
|
45
|
+
|
|
46
|
+
DSH and Claude Code store their keys separately. An explicit session key takes priority, followed by `ONEENDPOINT_API_KEY`, then the selected tool's saved key. The helper never puts keys in generated tool settings, model configuration, backups or logs.
|
|
47
|
+
|
|
48
|
+
### Optional: use an environment variable
|
|
49
|
+
|
|
50
|
+
If you prefer a secret manager or shell environment instead of local storage, provide `ONEENDPOINT_API_KEY`. For **Bash**, this hidden prompt keeps the key out of the command itself:
|
|
40
51
|
|
|
41
52
|
```bash
|
|
42
53
|
read -r -s -p "1endpoint API key: " ONEENDPOINT_API_KEY
|
|
@@ -60,7 +71,7 @@ For Claude Code:
|
|
|
60
71
|
npx @1endpoint/coding-helper doctor --tool claude
|
|
61
72
|
```
|
|
62
73
|
|
|
63
|
-
Doctor checks your saved connection configuration, whether the tool is installed, and whether
|
|
74
|
+
Doctor checks your saved connection configuration, whether the tool is installed, and whether an environment or saved API key is available. It does not validate the key against the server or make an inference request.
|
|
64
75
|
|
|
65
76
|
## 4. Start a new tool session
|
|
66
77
|
|
|
@@ -130,7 +141,13 @@ npx @1endpoint/coding-helper remove --tool dsh --dry-run
|
|
|
130
141
|
npx @1endpoint/coding-helper remove --tool dsh --yes
|
|
131
142
|
```
|
|
132
143
|
|
|
133
|
-
Use `--tool claude` for Claude Code.
|
|
144
|
+
Use `--tool claude` for Claude Code. The menu asks whether to **keep or delete the saved API key**. For scripted removal, add `--remove-key` to delete it explicitly:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npx @1endpoint/coding-helper remove --tool dsh --yes --remove-key
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Without that choice, the saved key is retained. Removal deletes only the helper-generated connection configuration and, when selected, its saved key. **Your original tool settings, runtime conversations and configuration backups are preserved.** To return to your ordinary setup, launch `dsh` or `claude` directly.
|
|
134
151
|
|
|
135
152
|
### Recover from damaged connection configuration
|
|
136
153
|
|
|
@@ -162,7 +179,7 @@ This is an independent integration, not an official DeepSeek or Anthropic produc
|
|
|
162
179
|
| --- | --- |
|
|
163
180
|
| Node version error | Install Node.js 22.19 or newer, then reopen your terminal. |
|
|
164
181
|
| CLI not found | Install the selected coding tool and make sure its executable is on your `PATH`. |
|
|
165
|
-
| API key missing |
|
|
182
|
+
| API key missing | Open the helper and paste a key, or press Enter on Open Console to create one. Choose Save locally for future launches. |
|
|
166
183
|
| Catalog unavailable | Choose Retry, or explicitly choose manual entry with a known public model ID. |
|
|
167
184
|
| New model is not taking effect | Close the old session and start a new one with the helper's Run action. |
|
|
168
185
|
| Ordinary tool launch ignores the helper | Launch through `npx @1endpoint/coding-helper run --tool dsh` or `--tool claude`. |
|
package/package.json
CHANGED
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/** Explicitly open only the Console create-key page; never accepts a caller URL. */
|
|
4
|
+
export function openConsole({ language = 'en', spawnImpl = spawn, platform = process.platform } = {}) {
|
|
5
|
+
const url = language === 'vi' ? 'https://console.1endpoint.dev/vi/api-keys' : 'https://console.1endpoint.dev/api-keys'
|
|
6
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32.exe' : 'xdg-open'
|
|
7
|
+
const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
let child, timer, settled = false
|
|
10
|
+
const fail = () => {
|
|
11
|
+
const error = new Error('Could not open Console. Try again or open the Console API keys page manually.')
|
|
12
|
+
error.code = 'BROWSER_OPEN_FAILED'
|
|
13
|
+
return error
|
|
14
|
+
}
|
|
15
|
+
const finish = error => {
|
|
16
|
+
if (settled) return
|
|
17
|
+
settled = true
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
if (child) {
|
|
20
|
+
child.off('error', onError)
|
|
21
|
+
child.off('close', onClose)
|
|
22
|
+
// Absorb a late spawn error after timeout.
|
|
23
|
+
child.on('error', () => {})
|
|
24
|
+
}
|
|
25
|
+
if (error) reject(error)
|
|
26
|
+
else resolve()
|
|
27
|
+
}
|
|
28
|
+
const onError = () => finish(fail())
|
|
29
|
+
const onClose = code => finish(code === 0 ? undefined : fail())
|
|
30
|
+
try {
|
|
31
|
+
child = spawnImpl(command, args, { shell: false, stdio: 'ignore', windowsHide: true })
|
|
32
|
+
child.once('error', onError)
|
|
33
|
+
child.once('close', onClose)
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
try { child.kill() } catch {}
|
|
36
|
+
finish(fail())
|
|
37
|
+
}, 5000)
|
|
38
|
+
} catch { finish(fail()) }
|
|
39
|
+
})
|
|
40
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -7,13 +7,14 @@ import { wizard, colorize } from './ui.mjs'
|
|
|
7
7
|
import { setup, reconfigure, remove, backup, loadState, stateRoot, validateTool, assertSafePath } from './state.mjs'
|
|
8
8
|
import { launchClaude, childEnvironment } from './adapters/claude.mjs'
|
|
9
9
|
import { launchDsh } from './adapters/dsh.mjs'
|
|
10
|
+
import { getCredential, saveCredential, removeCredential } from './credentials.mjs'
|
|
10
11
|
|
|
11
12
|
const HELP = `@1endpoint/coding-helper — isolated 1endpoint coding-tool setup
|
|
12
13
|
|
|
13
14
|
Usage:
|
|
14
15
|
coding-helper setup --tool dsh|claude --model PUBLIC_MODEL [--dry-run] [--update]
|
|
15
16
|
coding-helper reconfigure --tool dsh|claude --model PUBLIC_MODEL [--dry-run]
|
|
16
|
-
coding-helper remove --tool dsh|claude [--yes] [--dry-run] [--force]
|
|
17
|
+
coding-helper remove --tool dsh|claude [--yes] [--dry-run] [--force] [--remove-key]
|
|
17
18
|
coding-helper doctor --tool dsh|claude
|
|
18
19
|
coding-helper run --tool dsh|claude [--profile web|headless|acp|sdk] -- [tool args]
|
|
19
20
|
coding-helper backup --tool dsh|claude
|
|
@@ -24,7 +25,9 @@ Wizard fetches public 1endpoint text models for independent tool/tier selections
|
|
|
24
25
|
Claude setup/reconfigure accepts --haiku-model ID --sonnet-model ID --opus-model ID
|
|
25
26
|
and optional --fable-model ID for supporting versions. --model is the main/default fallback.
|
|
26
27
|
Node.js >=22.19 is required.
|
|
27
|
-
API key
|
|
28
|
+
Paste your API key privately in the menu, or press Enter on Open Console to create one.
|
|
29
|
+
Pasted keys are saved automatically for the selected tool. Saved files are private plaintext, not encrypted.
|
|
30
|
+
ONEENDPOINT_API_KEY remains an optional environment override; never pass keys as arguments.
|
|
28
31
|
State: ONEENDPOINT_HELPER_HOME or ~/.1endpoint/coding-helper (isolated tool homes).
|
|
29
32
|
No existing DSH/Claude settings are edited. Setup/doctor make no API requests.
|
|
30
33
|
Claude launch excludes project/local settings, hooks and permissions; managed policy applies.
|
|
@@ -38,12 +41,12 @@ function parse(argv) {
|
|
|
38
41
|
while (args.length) {
|
|
39
42
|
const arg = args.shift()
|
|
40
43
|
if (arg === '--') { forwarded.push(...args); break }
|
|
41
|
-
if (['--dry-run', '--update', '--yes', '--force'].includes(arg)) { if (options[arg]) throw new Error('Duplicate option.'); options[arg] = true; continue }
|
|
44
|
+
if (['--dry-run', '--update', '--yes', '--force', '--remove-key'].includes(arg)) { if (options[arg]) throw new Error('Duplicate option.'); options[arg] = true; continue }
|
|
42
45
|
if (!['--tool', '--model', '--profile', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'].includes(arg)) throw new Error('Unknown option. Use --help. API keys must never be command-line arguments.')
|
|
43
46
|
if (options[arg] !== undefined || !args.length || args[0].startsWith('-')) throw new Error('Missing or duplicate option value.')
|
|
44
47
|
options[arg] = args.shift()
|
|
45
48
|
}
|
|
46
|
-
const allowed = { setup: ['--tool', '--model', '--dry-run', '--update', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], reconfigure: ['--tool', '--model', '--dry-run', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], remove: ['--tool', '--yes', '--dry-run', '--force'], doctor: ['--tool'], backup: ['--tool'], run: ['--tool', '--profile'] }
|
|
49
|
+
const allowed = { setup: ['--tool', '--model', '--dry-run', '--update', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], reconfigure: ['--tool', '--model', '--dry-run', '--haiku-model', '--sonnet-model', '--opus-model', '--fable-model'], remove: ['--tool', '--yes', '--dry-run', '--force', '--remove-key'], doctor: ['--tool'], backup: ['--tool'], run: ['--tool', '--profile'] }
|
|
47
50
|
if (!allowed[command] || Object.keys(options).some(key => !allowed[command].includes(key)) || (command !== 'run' && forwarded.length)) throw new Error('Invalid command/options. Use --help.')
|
|
48
51
|
validateTool(options['--tool'])
|
|
49
52
|
if (options['--tool'] !== 'claude' && ['haiku', 'sonnet', 'opus', 'fable'].some(role => options[`--${role}-model`])) throw new Error('Tier model flags are only supported for Claude.')
|
|
@@ -63,7 +66,10 @@ async function executable(command, env) {
|
|
|
63
66
|
return null
|
|
64
67
|
}
|
|
65
68
|
async function main(argv, context = {}) {
|
|
66
|
-
if (!argv.length) return wizard({ dispatch: main })
|
|
69
|
+
if (!argv.length) return wizard({ dispatch: main, getKeyStatus: async ({ tool }) => {
|
|
70
|
+
const credential = await getCredential({ stateDir: stateRoot(), tool })
|
|
71
|
+
return { available: Boolean(credential.key), source: credential.source }
|
|
72
|
+
} })
|
|
67
73
|
if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { console.log(HELP); return }
|
|
68
74
|
if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) {
|
|
69
75
|
const metadata = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
|
@@ -76,8 +82,12 @@ async function main(argv, context = {}) {
|
|
|
76
82
|
const result = await remove({ stateDir, tool, dryRun: options['--dry-run'], yes: options['--yes'], force: options['--force'] })
|
|
77
83
|
console.log(`${result.dryRun ? t('Preview removal', 'Xem trước việc xóa') : result.changed ? t('Removed helper configuration', 'Đã xóa cấu hình riêng') : t('Already removed', 'Cấu hình đã được xóa')}: ${tool}`)
|
|
78
84
|
for (const file of result.files) console.log(` ${file}`)
|
|
85
|
+
if (options['--remove-key'] || context.removeKey) {
|
|
86
|
+
await removeCredential({ stateDir, tool, dryRun: Boolean(options['--dry-run']) })
|
|
87
|
+
console.log(options['--dry-run'] ? t('Saved-key removal preview only.', 'Chỉ xem trước việc xóa khóa đã lưu.') : t('Saved API key removed (if present).', 'Đã xóa khóa API đã lưu (nếu có).'))
|
|
88
|
+
}
|
|
79
89
|
if (result.force) console.log(t('Forced recovery removes only fixed helper configuration paths; modified contents are not backed up.', 'Khôi phục cưỡng chế chỉ xóa đường dẫn cấu hình riêng cố định; nội dung đã sửa không được sao lưu.'))
|
|
80
|
-
console.log(t('
|
|
90
|
+
console.log(t('Original tool settings, conversations and backups are unchanged.', 'Cài đặt gốc, hội thoại và bản sao lưu được giữ nguyên.'))
|
|
81
91
|
return
|
|
82
92
|
}
|
|
83
93
|
if (command === 'setup' || command === 'reconfigure') {
|
|
@@ -87,25 +97,31 @@ async function main(argv, context = {}) {
|
|
|
87
97
|
for (const file of result.files) console.log(` ${file.path}`)
|
|
88
98
|
if (context.language !== 'vi') for (const hint of result.hints || []) console.log(hint)
|
|
89
99
|
else console.log(tool === 'claude' ? 'Không nạp cài đặt, hook và quyền project/local. Chính sách quản trị vẫn áp dụng; không bỏ qua quyền.' : 'DSH dùng home và cấu hình riêng; không sửa cài đặt gốc. Chế độ sdk-minimal không được hỗ trợ.')
|
|
90
|
-
|
|
100
|
+
if (!result.dryRun && context.apiKey && context.saveKey) {
|
|
101
|
+
await saveCredential({ stateDir, tool, key: context.apiKey })
|
|
102
|
+
console.log(t('API key saved privately for this tool. Local storage is not encrypted.', 'Đã lưu khóa API riêng cho công cụ này. Tệp lưu cục bộ không được mã hóa.'))
|
|
103
|
+
} else if (context.apiKey) console.log(t('API key is available for this session only.', 'Khóa API chỉ dùng trong phiên này.'))
|
|
104
|
+
else console.log(t('Use the interactive menu to paste an API key, or provide ONEENDPOINT_API_KEY in your environment.', 'Mở menu để dán khóa API, hoặc cung cấp ONEENDPOINT_API_KEY qua môi trường.'))
|
|
91
105
|
if (!result.dryRun && !context.language) console.log(colorize('RESTART REQUIRED: existing sessions are not updated. Start a new session through coding-helper run --tool ' + tool + '.', 'warning'))
|
|
92
106
|
return
|
|
93
107
|
}
|
|
94
108
|
if (command === 'backup') { console.log('Secret-free configuration backup: ' + await backup({ stateDir, tool })); return }
|
|
95
109
|
const { state, plan } = await loadState(stateDir, tool)
|
|
110
|
+
const credential = context.apiKey ? { key: context.apiKey, source: 'session' } : await getCredential({ stateDir, tool })
|
|
96
111
|
const bin = await executable(plan.command, process.env)
|
|
97
112
|
if (command === 'doctor') {
|
|
98
113
|
console.log(`${t('Configuration integrity: OK', 'Tính toàn vẹn cấu hình: OK')} (${tool})`)
|
|
99
114
|
console.log(`${t('CLI installed', 'Đã cài CLI')}: ${bin ? t('yes', 'có') : t('no', 'chưa')}`)
|
|
100
|
-
console.log(
|
|
115
|
+
console.log(`${t('API key', 'Khóa API')}: ${credential.key ? t('available (not validated)', 'đã có (chưa xác thực)') : t('missing', 'chưa có')}`)
|
|
101
116
|
console.log(t('No credentials read from tool files. No network or inference check performed.', 'Không đọc thông tin đăng nhập từ tệp công cụ. Không kiểm tra mạng hoặc gọi mô hình.'))
|
|
102
117
|
if (context.language !== 'vi') for (const hint of plan.hints || []) console.log(hint)
|
|
103
|
-
if (!bin || !
|
|
118
|
+
if (!bin || !credential.key) process.exitCode = 1
|
|
104
119
|
return
|
|
105
120
|
}
|
|
106
121
|
if (!bin) throw new Error('CLI not found on PATH. Install the tool from its official distribution first.')
|
|
107
|
-
const key =
|
|
108
|
-
if (!key || /[\
|
|
122
|
+
const key = credential.key
|
|
123
|
+
if (!key || /[\s\x00-\x1f\x7f-\x9f]/.test(key)) throw new Error(t('Add an API key through the interactive menu or set ONEENDPOINT_API_KEY.', 'Thêm khóa API qua menu hoặc đặt ONEENDPOINT_API_KEY.'))
|
|
124
|
+
if (context.apiKey && context.saveKey) await saveCredential({ stateDir, tool, key })
|
|
109
125
|
let launch
|
|
110
126
|
try { launch = (tool === 'dsh' ? launchDsh : launchClaude)({ stateDir, model: state.model, roleModels: state.roles, args: forwarded, profile: options['--profile'] || 'web' }) }
|
|
111
127
|
catch { throw new Error('Unsupported tool arguments: routing, agent/plugin and permission-bypass overrides are not allowed.') }
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { assertSafePath, validateTool } from './state.mjs'
|
|
6
|
+
|
|
7
|
+
// Opt-in local storage is plaintext, NOT encryption. Credentials are kept only in
|
|
8
|
+
// credentials/<tool>.json (0600), inside credentials/ (0700), never manifests or
|
|
9
|
+
// backups. Atomic replacement leaves the previous credential intact on failure.
|
|
10
|
+
const failure = () => new Error('Unable to access a safe credential store. Check ownership, permissions, and saved credential format.')
|
|
11
|
+
const invalidKey = () => new Error('Provide a valid API key token without whitespace or control characters.')
|
|
12
|
+
const MAX_BYTES = 16384
|
|
13
|
+
const posix = process.platform !== 'win32'
|
|
14
|
+
|
|
15
|
+
function token(key) {
|
|
16
|
+
if (typeof key !== 'string' || !key.length || key.length > 8192 || /[\s\p{Cc}\p{Cf}]/u.test(key)) throw invalidKey()
|
|
17
|
+
return key
|
|
18
|
+
}
|
|
19
|
+
function locations(stateDir, tool) {
|
|
20
|
+
validateTool(tool)
|
|
21
|
+
if (typeof stateDir !== 'string' || !stateDir.length) throw failure()
|
|
22
|
+
const root = path.resolve(stateDir)
|
|
23
|
+
return { root, dir: path.join(root, 'credentials'), file: path.join(root, 'credentials', `${tool}.json`), lock: path.join(root, '.setup.lock') }
|
|
24
|
+
}
|
|
25
|
+
function owned(stat) {
|
|
26
|
+
if (posix && typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw failure()
|
|
27
|
+
}
|
|
28
|
+
function privateFile(stat) {
|
|
29
|
+
owned(stat)
|
|
30
|
+
if (!stat.isFile() || stat.nlink !== 1 || (posix && (stat.mode & 0o7777) !== 0o600) || stat.size > MAX_BYTES) throw failure()
|
|
31
|
+
}
|
|
32
|
+
async function optionalStat(file) {
|
|
33
|
+
try { return await fs.lstat(file) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
|
|
34
|
+
}
|
|
35
|
+
async function inspect(paths) {
|
|
36
|
+
await assertSafePath(paths.file)
|
|
37
|
+
const root = await optionalStat(paths.root)
|
|
38
|
+
if (root) {
|
|
39
|
+
owned(root)
|
|
40
|
+
if (!root.isDirectory() || (posix && (root.mode & 0o022))) throw failure()
|
|
41
|
+
}
|
|
42
|
+
const dir = await optionalStat(paths.dir)
|
|
43
|
+
if (dir) {
|
|
44
|
+
owned(dir)
|
|
45
|
+
if (!dir.isDirectory() || (posix && (dir.mode & 0o7777) !== 0o700)) throw failure()
|
|
46
|
+
}
|
|
47
|
+
const stat = await optionalStat(paths.file)
|
|
48
|
+
if (stat) privateFile(stat)
|
|
49
|
+
return stat
|
|
50
|
+
}
|
|
51
|
+
function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino }
|
|
52
|
+
async function readSaved(paths, tool) {
|
|
53
|
+
const stat = await inspect(paths)
|
|
54
|
+
if (!stat) return null
|
|
55
|
+
const handle = await fs.open(paths.file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
|
|
56
|
+
try {
|
|
57
|
+
const opened = await handle.stat()
|
|
58
|
+
privateFile(opened)
|
|
59
|
+
if (!sameFile(stat, opened)) throw failure()
|
|
60
|
+
const raw = await handle.readFile('utf8')
|
|
61
|
+
const after = await handle.stat()
|
|
62
|
+
privateFile(after)
|
|
63
|
+
if (Buffer.byteLength(raw) > MAX_BYTES || !sameFile(opened, after)) throw failure()
|
|
64
|
+
const current = await inspect(paths)
|
|
65
|
+
if (!current || !sameFile(opened, current)) throw failure()
|
|
66
|
+
const saved = JSON.parse(raw)
|
|
67
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved) || Object.keys(saved).sort().join(',') !== 'key,tool,version' || saved.version !== 1 || saved.tool !== tool) throw failure()
|
|
68
|
+
return token(saved.key)
|
|
69
|
+
} finally { await handle.close() }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getCredential({ stateDir, tool, env = process.env }) {
|
|
73
|
+
validateTool(tool)
|
|
74
|
+
const key = env.ONEENDPOINT_API_KEY
|
|
75
|
+
// An invalid nonempty environment token must not silently fall back to disk.
|
|
76
|
+
if (key !== undefined && key !== '') return { key: token(key), source: 'environment' }
|
|
77
|
+
try {
|
|
78
|
+
const saved = await readSaved(locations(stateDir, tool), tool)
|
|
79
|
+
return saved === null ? { key: undefined, source: 'missing' } : { key: saved, source: 'saved' }
|
|
80
|
+
} catch { throw failure() }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function credentialExists({ stateDir, tool }) {
|
|
84
|
+
return (await getCredential({ stateDir, tool, env: {} })).source === 'saved'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function withLock(paths, operation) {
|
|
88
|
+
await inspect(paths)
|
|
89
|
+
await fs.mkdir(paths.root, { recursive: true, mode: 0o700 })
|
|
90
|
+
await inspect(paths)
|
|
91
|
+
await assertSafePath(paths.lock)
|
|
92
|
+
let lock
|
|
93
|
+
try { lock = await fs.open(paths.lock, 'wx', 0o600) } catch (error) {
|
|
94
|
+
if (error.code === 'EEXIST') throw new Error('Another setup may be active. Review the helper lock before retrying.')
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
try { return await operation() } finally {
|
|
98
|
+
const held = await lock.stat()
|
|
99
|
+
await lock.close()
|
|
100
|
+
await assertSafePath(paths.lock)
|
|
101
|
+
const current = await optionalStat(paths.lock)
|
|
102
|
+
if (!current || !sameFile(held, current)) throw failure()
|
|
103
|
+
await fs.unlink(paths.lock)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function saveCredential({ stateDir, tool, key }) {
|
|
108
|
+
const paths = locations(stateDir, tool)
|
|
109
|
+
token(key)
|
|
110
|
+
try {
|
|
111
|
+
return await withLock(paths, async () => {
|
|
112
|
+
const previous = await readSaved(paths, tool)
|
|
113
|
+
if (previous === key) return { changed: false }
|
|
114
|
+
await fs.mkdir(paths.dir, { mode: 0o700, recursive: true })
|
|
115
|
+
await inspect(paths)
|
|
116
|
+
const temp = path.join(paths.dir, `.${tool}-${randomUUID()}.tmp`)
|
|
117
|
+
let handle
|
|
118
|
+
try {
|
|
119
|
+
handle = await fs.open(temp, 'wx', 0o600)
|
|
120
|
+
await handle.chmod(0o600)
|
|
121
|
+
await handle.writeFile(JSON.stringify({ version: 1, tool, key }) + '\n', 'utf8')
|
|
122
|
+
await handle.sync()
|
|
123
|
+
await handle.close()
|
|
124
|
+
handle = null
|
|
125
|
+
// The only commit is rename: all fallible preparation happens first.
|
|
126
|
+
await inspect(paths)
|
|
127
|
+
await assertSafePath(temp)
|
|
128
|
+
privateFile(await fs.lstat(temp))
|
|
129
|
+
await fs.rename(temp, paths.file)
|
|
130
|
+
return { changed: true }
|
|
131
|
+
} finally {
|
|
132
|
+
if (handle) await handle.close()
|
|
133
|
+
await fs.unlink(temp).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.message === 'Another setup may be active. Review the helper lock before retrying.') throw error
|
|
138
|
+
throw failure()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function removeCredential({ stateDir, tool, dryRun = false }) {
|
|
143
|
+
const paths = locations(stateDir, tool)
|
|
144
|
+
try {
|
|
145
|
+
const present = await inspect(paths)
|
|
146
|
+
if (dryRun || !present) return { changed: false, dryRun }
|
|
147
|
+
return await withLock(paths, async () => {
|
|
148
|
+
const current = await inspect(paths)
|
|
149
|
+
if (!current) return { changed: false, dryRun: false }
|
|
150
|
+
// Fixed validated path only; malformed JSON can be safely removed too.
|
|
151
|
+
await fs.unlink(paths.file)
|
|
152
|
+
return { changed: true, dryRun: false }
|
|
153
|
+
})
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.message === 'Another setup may be active. Review the helper lock before retrying.') throw error
|
|
156
|
+
throw failure()
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/ui.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises'
|
|
2
2
|
import { fetchModels } from './catalog.mjs'
|
|
3
|
+
import { openConsole } from './browser.mjs'
|
|
3
4
|
import * as claudeAdapter from './adapters/claude.mjs'
|
|
4
5
|
|
|
5
6
|
const palette = { heading: 36, info: 34, success: 32, warning: 33, error: 31, muted: 90, selected: 35 }
|
|
@@ -31,12 +32,19 @@ export async function select({ message, choices, input = process.stdin, output =
|
|
|
31
32
|
const wasPaused = typeof input.isPaused === 'function' && input.isPaused()
|
|
32
33
|
let index = 0, rendered = false, buffer = '', timer, settled = false
|
|
33
34
|
const pageSize = Math.min(choices.length, 8, Math.max(1, (output.rows || 24) - 4))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
let renderedLines = 0
|
|
36
|
+
const wrap = text => {
|
|
37
|
+
const width = Math.max(4, (output.columns || 120) - 1)
|
|
38
|
+
const lines = ['']
|
|
39
|
+
let cells = 0
|
|
40
|
+
for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(clean(text))) {
|
|
41
|
+
const cp = segment.codePointAt(0)
|
|
42
|
+
const size = /^\p{Mark}+$/u.test(segment) ? 0 : /\p{Extended_Pictographic}/u.test(segment) || cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff || cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0x20000) ? 2 : 1
|
|
43
|
+
if (cells + size > width) { lines.push(''); cells = 0 }
|
|
44
|
+
lines[lines.length - 1] += segment
|
|
45
|
+
cells += size
|
|
46
|
+
}
|
|
47
|
+
return lines
|
|
40
48
|
}
|
|
41
49
|
return new Promise((resolve, reject) => {
|
|
42
50
|
const cleanup = () => {
|
|
@@ -59,16 +67,24 @@ export async function select({ message, choices, input = process.stdin, output =
|
|
|
59
67
|
}
|
|
60
68
|
function onFailure() { finish(uiError()) }
|
|
61
69
|
function draw() {
|
|
62
|
-
if (rendered) output.write(`\u001b[${
|
|
63
|
-
|
|
70
|
+
if (rendered) output.write(`\u001b[${renderedLines}A\r\u001b[J`)
|
|
71
|
+
const rows = wrap(message).map(text => ({ text, kind: 'heading' }))
|
|
64
72
|
const start = Math.min(Math.max(0, index - pageSize + 1), choices.length - pageSize)
|
|
65
|
-
choices.slice(start, start + pageSize).forEach((choice, offset) => {
|
|
66
|
-
const i = start + offset
|
|
67
|
-
const text = `${i === index ? '❯' : ' '} ${choice.label}${choice.description ? ` — ${choice.description}` : ''}`
|
|
68
|
-
output.write('\u001b[2K' + colorize(fit(text), i === index ? 'selected' : 'info', { output }) + '\n')
|
|
69
|
-
})
|
|
70
73
|
const hint = language === 'vi' ? '↑/↓ chọn · Enter xác nhận · Esc hủy' : '↑/↓ select · Enter accept · Esc cancel'
|
|
71
|
-
|
|
74
|
+
const footer = wrap(`${index + 1}/${choices.length} · ${hint}`)
|
|
75
|
+
let candidates = choices.slice(start, start + pageSize).map((choice, offset) => ({
|
|
76
|
+
i: start + offset,
|
|
77
|
+
lines: wrap(`${start + offset === index ? '❯' : ' '} ${choice.label}${choice.description ? ` — ${choice.description}` : ''}`)
|
|
78
|
+
}))
|
|
79
|
+
const budget = Math.max(1, (output.rows || 24) - rows.length - footer.length - 1)
|
|
80
|
+
while (candidates.length > 1 && candidates.reduce((n, c) => n + c.lines.length, 0) > budget) {
|
|
81
|
+
if (candidates[0].i < index) candidates.shift()
|
|
82
|
+
else candidates.pop()
|
|
83
|
+
}
|
|
84
|
+
for (const candidate of candidates) for (const text of candidate.lines) rows.push({ text, kind: candidate.i === index ? 'selected' : 'info' })
|
|
85
|
+
for (const text of footer) rows.push({ text, kind: 'muted' })
|
|
86
|
+
for (const { text, kind } of rows) output.write('\u001b[2K' + colorize(text, kind, { output }) + '\n')
|
|
87
|
+
renderedLines = rows.length
|
|
72
88
|
rendered = true
|
|
73
89
|
}
|
|
74
90
|
function onData(chunk) {
|
|
@@ -114,6 +130,81 @@ export async function select({ message, choices, input = process.stdin, output =
|
|
|
114
130
|
})
|
|
115
131
|
}
|
|
116
132
|
|
|
133
|
+
/** Masked single-token entry. Bracketed paste is buffered until its end marker. */
|
|
134
|
+
export async function readApiKey({ input = process.stdin, output = process.stdout, language = 'en' } = {}) {
|
|
135
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') throw uiError()
|
|
136
|
+
const wasRaw = Boolean(input.isRaw), wasPaused = input.isPaused?.()
|
|
137
|
+
let value = '', buffer = '', pasting = false, pasted = '', settled = false, timer
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const finish = (error, result) => {
|
|
140
|
+
if (settled) return
|
|
141
|
+
settled = true
|
|
142
|
+
clearTimeout(timer)
|
|
143
|
+
for (const event of ['data', 'error', 'end', 'close']) input.off(event, event === 'data' ? onData : onFailure)
|
|
144
|
+
output.off('error', onFailure)
|
|
145
|
+
try { input.setRawMode(wasRaw) } catch {}
|
|
146
|
+
try { if (wasPaused) input.pause() } catch {}
|
|
147
|
+
try { output.write('\u001b[?2004l\n') } catch {}
|
|
148
|
+
value = ''; pasted = ''; buffer = ''
|
|
149
|
+
if (error) reject(error)
|
|
150
|
+
else resolve(result)
|
|
151
|
+
}
|
|
152
|
+
const invalid = () => {
|
|
153
|
+
const error = new Error(language === 'vi' ? 'Khóa API phải là một chuỗi duy nhất, không có khoảng trắng hoặc ký tự điều khiển.' : 'API key must be a single token without whitespace or control characters.')
|
|
154
|
+
error.code = 'UI_INVALID_KEY'
|
|
155
|
+
finish(error)
|
|
156
|
+
}
|
|
157
|
+
const valid = text => /^[\x21-\x7e]+$/.test(text) && text.length <= 8192
|
|
158
|
+
function onFailure() { finish(uiError()) }
|
|
159
|
+
function onData(chunk) {
|
|
160
|
+
try {
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
buffer += chunk.toString('utf8')
|
|
163
|
+
while (buffer && !settled) {
|
|
164
|
+
if (pasting) {
|
|
165
|
+
const end = buffer.indexOf('\u001b[201~')
|
|
166
|
+
if (end < 0) {
|
|
167
|
+
// Retain a possible split terminator; no pasted byte is interpreted as a key.
|
|
168
|
+
if (buffer.length > 6) { pasted += buffer.slice(0, -6); buffer = buffer.slice(-6) }
|
|
169
|
+
if (pasted.length > 8192) return invalid()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
pasted += buffer.slice(0, end)
|
|
173
|
+
buffer = buffer.slice(end + 6)
|
|
174
|
+
if (!valid(pasted) || value.length + pasted.length > 8192) return invalid()
|
|
175
|
+
value += pasted; output.write('*'.repeat(pasted.length)); pasted = ''; pasting = false
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (buffer.startsWith('\u001b[200~')) { buffer = buffer.slice(6); pasting = true; continue }
|
|
179
|
+
if ('\u001b[200~'.startsWith(buffer) && buffer[0] === '\u001b') {
|
|
180
|
+
timer = setTimeout(() => finish(uiError(true)), 35); return
|
|
181
|
+
}
|
|
182
|
+
const char = buffer[0]; buffer = buffer.slice(1)
|
|
183
|
+
if (char === '\u0003' || char === '\u001b') return finish(uiError(true))
|
|
184
|
+
if (char === '\r' || char === '\n') {
|
|
185
|
+
if (!valid(value) || buffer.length) return invalid()
|
|
186
|
+
return finish(null, value)
|
|
187
|
+
}
|
|
188
|
+
if (char === '\x7f' || char === '\b') {
|
|
189
|
+
if (value) { value = value.slice(0, -1); output.write('\b \b') }
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (!valid(char) || value.length >= 8192) return invalid()
|
|
193
|
+
value += char; output.write('*')
|
|
194
|
+
}
|
|
195
|
+
} catch { finish(uiError()) }
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
input.on('data', onData)
|
|
199
|
+
for (const event of ['error', 'end', 'close']) input.on(event, onFailure)
|
|
200
|
+
output.on('error', onFailure)
|
|
201
|
+
input.setRawMode(true)
|
|
202
|
+
output.write('\u001b[?2004h' + (language === 'vi' ? 'Dán khóa API: ' : 'Paste API key: '))
|
|
203
|
+
input.resume()
|
|
204
|
+
} catch { finish(uiError()) }
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
117
208
|
async function modelQuestion(input, output, language = 'en') {
|
|
118
209
|
const controller = new AbortController()
|
|
119
210
|
const terminal = createInterface({ input, output, terminal: true })
|
|
@@ -139,7 +230,7 @@ async function modelQuestion(input, output, language = 'en') {
|
|
|
139
230
|
}
|
|
140
231
|
|
|
141
232
|
/** Gather CLI arguments only. State and tool execution belong to dispatch. */
|
|
142
|
-
export async function wizard({ dispatch, input = process.stdin, output = process.stdout, loadModels = fetchModels }) {
|
|
233
|
+
export async function wizard({ dispatch, input = process.stdin, output = process.stdout, loadModels = fetchModels, getKeyStatus = async () => ({ available: false }), openConsole: openBrowser = openConsole }) {
|
|
143
234
|
if (typeof dispatch !== 'function') throw new Error('Wizard requires a CLI dispatcher.')
|
|
144
235
|
let language = 'en'
|
|
145
236
|
const t = (en, vi) => language === 'vi' ? vi : en
|
|
@@ -152,7 +243,7 @@ export async function wizard({ dispatch, input = process.stdin, output = process
|
|
|
152
243
|
language = await choose('Language / Ngôn ngữ', [
|
|
153
244
|
{ label: 'English', value: 'en' }, { label: 'Tiếng Việt', value: 'vi' }
|
|
154
245
|
])
|
|
155
|
-
const action = await choose(t('1endpoint coding-helper · isolated configuration
|
|
246
|
+
const action = await choose(t('1endpoint coding-helper · isolated configuration', '1endpoint coding-helper · cấu hình riêng'), [
|
|
156
247
|
{ label: t('Setup', 'Cấu hình'), value: 'setup' },
|
|
157
248
|
{ label: t('Reconfigure', 'Cấu hình lại'), value: 'reconfigure' },
|
|
158
249
|
{ label: t('Remove', 'Xóa cấu hình'), value: 'remove' },
|
|
@@ -165,6 +256,38 @@ export async function wizard({ dispatch, input = process.stdin, output = process
|
|
|
165
256
|
{ label: 'DSH', value: 'dsh' }, { label: 'Claude Code', value: 'claude' }
|
|
166
257
|
])
|
|
167
258
|
const argv = [action, '--tool', tool]
|
|
259
|
+
const context = { language }
|
|
260
|
+
if (['setup', 'reconfigure', 'run'].includes(action)) {
|
|
261
|
+
let available = false
|
|
262
|
+
try { available = (await getKeyStatus({ tool }))?.available === true } catch {}
|
|
263
|
+
if (action !== 'run' || !available) {
|
|
264
|
+
while (true) {
|
|
265
|
+
const keyAction = await choose(t('API key', 'Khóa API'), [
|
|
266
|
+
{ label: t('Paste API key', 'Dán khóa API'), value: 'paste' },
|
|
267
|
+
{ label: t('Open Console create API key', 'Mở Console tạo khóa API'), value: 'open', description: t('Enter opens the API keys page', 'Enter mở trang khóa API') },
|
|
268
|
+
...(available ? [{ label: t('Use existing saved/environment key', 'Dùng khóa đã lưu hoặc biến môi trường'), value: 'existing' }] : []),
|
|
269
|
+
{ label: t('Skip; use environment key later', 'Bỏ qua; dùng khóa môi trường sau'), value: 'skip' },
|
|
270
|
+
{ label: t('Cancel', 'Hủy'), value: 'cancel' }
|
|
271
|
+
])
|
|
272
|
+
if (keyAction === 'cancel') return
|
|
273
|
+
if (keyAction === 'existing' || keyAction === 'skip') break
|
|
274
|
+
if (keyAction === 'open') {
|
|
275
|
+
try { await openBrowser({ language }) }
|
|
276
|
+
catch { output.write(t('Could not open Console. Select Open Console to retry, or paste a key.\n', 'Không mở được Console. Chọn Mở Console để thử lại hoặc dán khóa.\n')) }
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
let key
|
|
280
|
+
try { key = await readApiKey({ input, output, language }) }
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code !== 'UI_INVALID_KEY') throw error
|
|
283
|
+
output.write(error.message + '\n'); continue
|
|
284
|
+
}
|
|
285
|
+
output.write(t('Local keys are plaintext, mode 0600: accessible to your user account, not encrypted.\n', 'Khóa cục bộ ở dạng văn bản thuần, quyền 0600: tài khoản của bạn có thể đọc, không được mã hóa.\n'))
|
|
286
|
+
context.apiKey = key; context.saveKey = true
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
168
291
|
if (action === 'setup' || action === 'reconfigure') {
|
|
169
292
|
let models, manual = false
|
|
170
293
|
while (!models && !manual) {
|
|
@@ -192,7 +315,7 @@ export async function wizard({ dispatch, input = process.stdin, output = process
|
|
|
192
315
|
const optionalRoles = claudeAdapter.CLAUDE_OPTIONAL_ROLES || []
|
|
193
316
|
let activeRoles = [...roles]
|
|
194
317
|
if (optionalRoles.length) {
|
|
195
|
-
const extra = await choose(t('
|
|
318
|
+
const extra = await choose(t('Select the tiers supported by your Claude Code version.', 'Chọn các cấp được phiên bản Claude Code của bạn hỗ trợ.'), [
|
|
196
319
|
{ label: t('Standard Haiku / Sonnet / Opus', 'Các cấp chuẩn Haiku / Sonnet / Opus'), value: false },
|
|
197
320
|
{ label: t('Include optional tiers', 'Thêm cấp tùy chọn'), value: true, description: t('Only if your installed Claude supports ', 'Chỉ chọn nếu Claude đã cài hỗ trợ ') + optionalRoles.map(role => role.label).join(', ') }
|
|
198
321
|
])
|
|
@@ -224,11 +347,15 @@ export async function wizard({ dispatch, input = process.stdin, output = process
|
|
|
224
347
|
])
|
|
225
348
|
if (mode === 'cancel') return
|
|
226
349
|
if (mode === 'force') output.write(colorize(t('WARNING: Force recovery deletes fixed helper configuration paths, including modified contents, without backup.\n', 'CẢNH BÁO: xóa cưỡng chế xóa các đường dẫn cấu hình riêng cố định, kể cả nội dung đã sửa, không sao lưu.\n'), 'warning', { output }))
|
|
350
|
+
context.removeKey = await choose(t('Saved key for this tool', 'Khóa đã lưu cho công cụ này'), [
|
|
351
|
+
{ label: t('Keep saved key', 'Giữ khóa đã lưu'), value: false },
|
|
352
|
+
{ label: t('Remove saved key too', 'Xóa cả khóa đã lưu'), value: true }
|
|
353
|
+
])
|
|
227
354
|
if (!await confirm(mode === 'force' ? t('Confirm forced recovery deletion without backup?', 'Xác nhận xóa cưỡng chế không sao lưu?') : t('Confirm removal of helper-owned configuration?', 'Xác nhận xóa cấu hình riêng?'))) return
|
|
228
355
|
argv.push('--yes')
|
|
229
356
|
if (mode === 'force') argv.push('--force')
|
|
230
357
|
}
|
|
231
|
-
const result = await dispatch(argv,
|
|
358
|
+
const result = await dispatch(argv, context)
|
|
232
359
|
if (action === 'setup' || action === 'reconfigure') {
|
|
233
360
|
const warning = t(`Restart your ${tool === 'dsh' ? 'DSH' : 'Claude Code'} session to apply. Existing sessions do not update automatically. Start a new session through coding-helper.\n`, `Khởi động lại phiên ${tool === 'dsh' ? 'DSH' : 'Claude Code'} để áp dụng. Phiên hiện có không tự cập nhật. Mở phiên mới thông qua coding-helper.\n`)
|
|
234
361
|
const colored = colorize(warning, 'warning', { output })
|