@mterminal/mtx 0.1.0

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,29 @@
1
+ import pc from 'picocolors'
2
+ import { registerKey } from '../lib/api'
3
+ import { generateKeyPair } from '../lib/sign'
4
+ import { writeKey } from '../lib/keystore'
5
+ import { loadConfig, updateConfig } from '../lib/config'
6
+
7
+ export interface KeygenOptions {
8
+ name?: string
9
+ setActive?: boolean
10
+ }
11
+
12
+ export async function keygenCommand(opts: KeygenOptions = {}): Promise<void> {
13
+ const cfg = await loadConfig()
14
+ if (!cfg.apiKey) {
15
+ console.log(pc.red('not logged in. run `mtx login` first.'))
16
+ process.exit(1)
17
+ }
18
+ const { priv, pubB64 } = await generateKeyPair()
19
+ const res = await registerKey(
20
+ { endpoint: cfg.endpoint, apiKey: cfg.apiKey },
21
+ { pubkeyB64: pubB64, name: opts.name },
22
+ )
23
+ await writeKey(res.keyId, priv, pubB64)
24
+ if (opts.setActive !== false) {
25
+ await updateConfig({ activeKeyId: res.keyId })
26
+ }
27
+ console.log(pc.green(`registered key ${res.keyId}`))
28
+ console.log(`active key: ${res.keyId}`)
29
+ }
@@ -0,0 +1,40 @@
1
+ import pc from 'picocolors'
2
+ import { deviceStart, devicePoll } from '../lib/api'
3
+ import { loadConfig, updateConfig } from '../lib/config'
4
+
5
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
6
+
7
+ export async function loginCommand(): Promise<void> {
8
+ const cfg = await loadConfig()
9
+ console.log(pc.cyan(`endpoint: ${cfg.endpoint}`))
10
+ const start = await deviceStart({ endpoint: cfg.endpoint })
11
+ console.log()
12
+ console.log(`open ${pc.bold(start.verificationUri)} and enter code:`)
13
+ console.log(pc.bold(pc.yellow(start.userCode)))
14
+ console.log()
15
+
16
+ const deadline = Date.now() + start.expiresIn * 1000
17
+ let interval = Math.max(start.interval, 2)
18
+ while (Date.now() < deadline) {
19
+ await sleep(interval * 1000)
20
+ const poll = await devicePoll({ endpoint: cfg.endpoint }, start.deviceCode)
21
+ if (poll.status === 'authorized' && poll.apiKey) {
22
+ await updateConfig({
23
+ apiKey: poll.apiKey,
24
+ authorId: poll.authorId,
25
+ githubLogin: poll.githubLogin,
26
+ })
27
+ console.log(pc.green(`logged in as ${poll.githubLogin} (${poll.authorId})`))
28
+ return
29
+ }
30
+ if (poll.status === 'denied') {
31
+ console.log(pc.red('access denied'))
32
+ return
33
+ }
34
+ if (poll.status === 'expired') {
35
+ console.log(pc.red('device flow expired, try again'))
36
+ return
37
+ }
38
+ }
39
+ console.log(pc.red('timed out waiting for authorization'))
40
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import pc from 'picocolors'
4
+ import { pack } from '../lib/pack'
5
+ import { loadConfig } from '../lib/config'
6
+ import { readPrivKey } from '../lib/keystore'
7
+
8
+ export interface PackCommandOptions {
9
+ cwd?: string
10
+ out?: string
11
+ build?: boolean
12
+ }
13
+
14
+ import { spawn } from 'node:child_process'
15
+
16
+ function runNpmBuild(cwd: string): Promise<void> {
17
+ return new Promise((resolve, reject) => {
18
+ const child = spawn('npm', ['run', 'build'], { cwd, stdio: 'inherit' })
19
+ child.on('exit', (code) => {
20
+ if (code === 0) resolve()
21
+ else reject(new Error(`npm run build exited with ${code}`))
22
+ })
23
+ child.on('error', reject)
24
+ })
25
+ }
26
+
27
+ export async function packCommand(opts: PackCommandOptions = {}): Promise<string> {
28
+ const cwd = path.resolve(opts.cwd ?? process.cwd())
29
+ const cfg = await loadConfig()
30
+ if (!cfg.authorId || !cfg.activeKeyId) {
31
+ console.log(pc.red('not configured. run `mtx login` and `mtx keygen` first.'))
32
+ process.exit(1)
33
+ }
34
+
35
+ if (opts.build !== false) {
36
+ const pkgPath = path.join(cwd, 'package.json')
37
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as { scripts?: Record<string, string> }
38
+ if (pkg.scripts?.build) await runNpmBuild(cwd)
39
+ }
40
+
41
+ const priv = await readPrivKey(cfg.activeKeyId)
42
+ const result = await pack({
43
+ cwd,
44
+ authorId: cfg.authorId,
45
+ keyId: cfg.activeKeyId,
46
+ privKey: priv,
47
+ })
48
+
49
+ const out = path.resolve(opts.out ?? `${result.manifestId}-${result.version}.mtx`)
50
+ await fs.writeFile(out, result.buf)
51
+ console.log(pc.green(`packed ${result.manifestId}@${result.version} → ${out} (${result.buf.length} bytes)`))
52
+ return out
53
+ }
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import pc from 'picocolors'
4
+ import { loadConfig } from '../lib/config'
5
+ import { publishPackage } from '../lib/api'
6
+ import { packCommand } from './pack'
7
+
8
+ export interface PublishOptions {
9
+ file?: string
10
+ cwd?: string
11
+ build?: boolean
12
+ }
13
+
14
+ export async function publishCommand(opts: PublishOptions = {}): Promise<void> {
15
+ const cfg = await loadConfig()
16
+ if (!cfg.apiKey) {
17
+ console.log(pc.red('not logged in. run `mtx login` first.'))
18
+ process.exit(1)
19
+ }
20
+
21
+ let file: string
22
+ if (opts.file) {
23
+ file = path.resolve(opts.file)
24
+ } else {
25
+ file = await packCommand({ cwd: opts.cwd, build: opts.build })
26
+ }
27
+
28
+ const buf = new Uint8Array(await fs.readFile(file))
29
+ console.log(pc.cyan(`uploading ${path.basename(file)} (${buf.length} bytes)...`))
30
+ const result = await publishPackage(
31
+ { endpoint: cfg.endpoint, apiKey: cfg.apiKey },
32
+ buf,
33
+ )
34
+ if (result.ok) {
35
+ console.log(pc.green(`published ${result.id}@${result.version}`))
36
+ return
37
+ }
38
+ console.log(pc.red('publish failed:'))
39
+ for (const e of result.errors ?? []) {
40
+ console.log(pc.red(` [${e.code}]`))
41
+ for (const issue of e.issues) {
42
+ const loc = issue.path
43
+ ? ` (${issue.path}${issue.line ? `:${issue.line}` : ''}${issue.col ? `:${issue.col}` : ''})`
44
+ : ''
45
+ console.log(` - ${issue.message}${loc}`)
46
+ }
47
+ }
48
+ process.exit(1)
49
+ }
@@ -0,0 +1,14 @@
1
+ import pc from 'picocolors'
2
+ import { loadConfig } from '../lib/config'
3
+
4
+ export async function whoamiCommand(): Promise<void> {
5
+ const cfg = await loadConfig()
6
+ if (!cfg.apiKey) {
7
+ console.log(pc.yellow('not logged in'))
8
+ return
9
+ }
10
+ console.log(`author : ${cfg.authorId ?? '(unknown)'}`)
11
+ console.log(`login : ${cfg.githubLogin ?? '(unknown)'}`)
12
+ console.log(`key : ${cfg.activeKeyId ?? '(none)'}`)
13
+ console.log(`server : ${cfg.endpoint}`)
14
+ }
@@ -0,0 +1,13 @@
1
+ import pc from 'picocolors'
2
+ import { loadConfig } from '../lib/config'
3
+ import { yankVersion } from '../lib/api'
4
+
5
+ export async function yankCommand(id: string, version: string): Promise<void> {
6
+ const cfg = await loadConfig()
7
+ if (!cfg.apiKey) {
8
+ console.log(pc.red('not logged in'))
9
+ process.exit(1)
10
+ }
11
+ await yankVersion({ endpoint: cfg.endpoint, apiKey: cfg.apiKey }, id, version)
12
+ console.log(pc.green(`yanked ${id}@${version}`))
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { cac } from 'cac'
2
+ import { initCommand } from './commands/init'
3
+ import { loginCommand } from './commands/login'
4
+ import { keygenCommand } from './commands/keygen'
5
+ import { packCommand } from './commands/pack'
6
+ import { publishCommand } from './commands/publish'
7
+ import { yankCommand } from './commands/yank'
8
+ import { whoamiCommand } from './commands/whoami'
9
+
10
+ const VERSION = '0.1.0'
11
+
12
+ export async function run(argv: string[]): Promise<void> {
13
+ const cli = cac('mtx')
14
+
15
+ cli
16
+ .command('init [name]', 'scaffold a new extension')
17
+ .action(async (name?: string) => initCommand(name))
18
+
19
+ cli.command('login', 'authenticate via github device flow').action(async () => loginCommand())
20
+
21
+ cli
22
+ .command('keygen', 'generate and register a new ed25519 keypair')
23
+ .option('--name <name>', 'human-readable name for the key')
24
+ .action(async (opts: { name?: string }) => keygenCommand({ name: opts.name }))
25
+
26
+ cli
27
+ .command('pack', 'build and bundle the extension into a .mtx file')
28
+ .option('--out <file>', 'output file path')
29
+ .option('--no-build', 'skip npm run build')
30
+ .action(async (opts: { out?: string; build?: boolean }) =>
31
+ packCommand({ out: opts.out, build: opts.build }),
32
+ )
33
+
34
+ cli
35
+ .command('publish', 'pack and upload to the marketplace')
36
+ .option('--file <file>', 'use an existing .mtx file instead of packing')
37
+ .option('--no-build', 'skip npm run build when packing')
38
+ .action(async (opts: { file?: string; build?: boolean }) =>
39
+ publishCommand({ file: opts.file, build: opts.build }),
40
+ )
41
+
42
+ cli
43
+ .command('yank <id> <version>', 'mark a published version as yanked')
44
+ .action(async (id: string, version: string) => yankCommand(id, version))
45
+
46
+ cli.command('whoami', 'show current login info').action(async () => whoamiCommand())
47
+
48
+ cli.help()
49
+ cli.version(VERSION)
50
+
51
+ cli.parse(['node', 'mtx', ...argv], { run: false })
52
+ await cli.runMatchedCommand()
53
+ }
@@ -0,0 +1,138 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ deviceStart,
4
+ devicePoll,
5
+ devAuthorize,
6
+ registerKey,
7
+ publishPackage,
8
+ yankVersion,
9
+ search,
10
+ } from './api'
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals()
14
+ })
15
+
16
+ function stubFetch(
17
+ handler: (url: string, init?: RequestInit) => Response | Promise<Response>,
18
+ ): void {
19
+ const fn = vi.fn(async (input: unknown, init?: RequestInit) => {
20
+ const url = typeof input === 'string' ? input : String(input)
21
+ return handler(url, init)
22
+ })
23
+ vi.stubGlobal('fetch', fn)
24
+ }
25
+
26
+ function jsonResponse(body: unknown, status = 200): Response {
27
+ return new Response(JSON.stringify(body), {
28
+ status,
29
+ headers: { 'content-type': 'application/json' },
30
+ })
31
+ }
32
+
33
+ describe('api', () => {
34
+ it('deviceStart hits /v1/auth/device/start', async () => {
35
+ stubFetch((url) => {
36
+ expect(url).toBe('https://api.example.com/v1/auth/device/start')
37
+ return jsonResponse({
38
+ deviceCode: 'd',
39
+ userCode: 'u',
40
+ verificationUri: 'https://x',
41
+ expiresIn: 300,
42
+ interval: 5,
43
+ })
44
+ })
45
+ const r = await deviceStart({ endpoint: 'https://api.example.com' })
46
+ expect(r.deviceCode).toBe('d')
47
+ })
48
+
49
+ it('devicePoll posts deviceCode body', async () => {
50
+ stubFetch((url, init) => {
51
+ expect(url).toBe('https://api.example.com/v1/auth/device/poll')
52
+ expect(JSON.parse(String(init?.body))).toEqual({ deviceCode: 'd' })
53
+ return jsonResponse({ status: 'authorized', apiKey: 'mtx_x', authorId: 'gh-1' })
54
+ })
55
+ const r = await devicePoll({ endpoint: 'https://api.example.com' }, 'd')
56
+ expect(r.apiKey).toBe('mtx_x')
57
+ })
58
+
59
+ it('devAuthorize sends deviceCode + githubLogin', async () => {
60
+ stubFetch((url, init) => {
61
+ expect(url).toBe('https://api.example.com/v1/auth/device/dev-authorize')
62
+ expect(JSON.parse(String(init?.body))).toEqual({
63
+ deviceCode: 'd',
64
+ githubLogin: 'me',
65
+ })
66
+ return jsonResponse({ ok: true })
67
+ })
68
+ await devAuthorize({ endpoint: 'https://api.example.com' }, 'd', 'me')
69
+ })
70
+
71
+ it('registerKey requires apiKey and posts pubkey', async () => {
72
+ await expect(
73
+ registerKey({ endpoint: 'https://x' }, { pubkeyB64: 'aaa' }),
74
+ ).rejects.toThrow(/apiKey/)
75
+ stubFetch((_, init) => {
76
+ const auth = (init?.headers as Record<string, string>).authorization
77
+ expect(auth).toBe('Bearer mtx_xx')
78
+ return jsonResponse({ keyId: 'gh-1:key1' })
79
+ })
80
+ const r = await registerKey(
81
+ { endpoint: 'https://x', apiKey: 'mtx_xx' },
82
+ { pubkeyB64: 'aaa' },
83
+ )
84
+ expect(r.keyId).toBe('gh-1:key1')
85
+ })
86
+
87
+ it('publishPackage uploads multipart and returns PublishResult', async () => {
88
+ stubFetch(async (url, init) => {
89
+ expect(url).toBe('https://x/v1/publish')
90
+ expect(init?.body).toBeInstanceOf(FormData)
91
+ return jsonResponse({ ok: true, id: 'demo', version: '1.0.0' })
92
+ })
93
+ const r = await publishPackage(
94
+ { endpoint: 'https://x', apiKey: 'mtx_yy' },
95
+ new Uint8Array([1, 2, 3]),
96
+ )
97
+ expect(r.ok).toBe(true)
98
+ expect(r.id).toBe('demo')
99
+ })
100
+
101
+ it('publishPackage returns policy errors as-is', async () => {
102
+ stubFetch(() =>
103
+ jsonResponse(
104
+ { ok: false, errors: [{ code: 'manifest', issues: [{ message: 'bad' }] }] },
105
+ 400,
106
+ ),
107
+ )
108
+ const r = await publishPackage(
109
+ { endpoint: 'https://x', apiKey: 'mtx_yy' },
110
+ new Uint8Array([1]),
111
+ )
112
+ expect(r.ok).toBe(false)
113
+ expect(r.errors?.[0]?.code).toBe('manifest')
114
+ })
115
+
116
+ it('yankVersion calls the right path', async () => {
117
+ stubFetch((url) => {
118
+ expect(url).toBe('https://x/v1/extensions/foo/yank/1.0.0')
119
+ return jsonResponse({ ok: true })
120
+ })
121
+ await yankVersion({ endpoint: 'https://x', apiKey: 'mtx_z' }, 'foo', '1.0.0')
122
+ })
123
+
124
+ it('search appends query params', async () => {
125
+ stubFetch((url) => {
126
+ expect(url).toBe('https://x/v1/extensions?q=foo&category=ai')
127
+ return jsonResponse({ items: [], total: 0, page: 0, pageSize: 20 })
128
+ })
129
+ await search({ endpoint: 'https://x' }, { q: 'foo', category: 'ai' })
130
+ })
131
+
132
+ it('throws on non-2xx with error field', async () => {
133
+ stubFetch(() => jsonResponse({ error: 'not found' }, 404))
134
+ await expect(
135
+ search({ endpoint: 'https://x' }, { q: 'foo' }),
136
+ ).rejects.toThrow(/HTTP 404/)
137
+ })
138
+ })
package/src/lib/api.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type {
2
+ DeviceFlowPollResult,
3
+ DeviceFlowStartResult,
4
+ KeyRegisterRequest,
5
+ KeyRegisterResponse,
6
+ PublishResult,
7
+ SearchResult,
8
+ } from '@mterminal/marketplace-types'
9
+
10
+ export interface ApiOptions {
11
+ endpoint: string
12
+ apiKey?: string
13
+ }
14
+
15
+ async function expectJson<T>(res: Response): Promise<T> {
16
+ const text = await res.text()
17
+ let json: unknown
18
+ try {
19
+ json = JSON.parse(text)
20
+ } catch {
21
+ throw new Error(`expected json response, got: ${text.slice(0, 200)}`)
22
+ }
23
+ if (!res.ok) {
24
+ const body = json as { error?: string; errors?: unknown }
25
+ throw new Error(`HTTP ${res.status}: ${body.error ?? JSON.stringify(json)}`)
26
+ }
27
+ return json as T
28
+ }
29
+
30
+ export async function deviceStart(opts: ApiOptions): Promise<DeviceFlowStartResult> {
31
+ const r = await fetch(`${opts.endpoint}/v1/auth/device/start`, { method: 'POST' })
32
+ return expectJson<DeviceFlowStartResult>(r)
33
+ }
34
+
35
+ export async function devicePoll(
36
+ opts: ApiOptions,
37
+ deviceCode: string,
38
+ ): Promise<DeviceFlowPollResult> {
39
+ const r = await fetch(`${opts.endpoint}/v1/auth/device/poll`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ deviceCode }),
43
+ })
44
+ return expectJson<DeviceFlowPollResult>(r)
45
+ }
46
+
47
+ export async function devAuthorize(
48
+ opts: ApiOptions,
49
+ deviceCode: string,
50
+ githubLogin: string,
51
+ ): Promise<void> {
52
+ const r = await fetch(`${opts.endpoint}/v1/auth/device/dev-authorize`, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ deviceCode, githubLogin }),
56
+ })
57
+ await expectJson<{ ok: boolean }>(r)
58
+ }
59
+
60
+ export async function registerKey(
61
+ opts: ApiOptions,
62
+ body: KeyRegisterRequest,
63
+ ): Promise<KeyRegisterResponse> {
64
+ if (!opts.apiKey) throw new Error('apiKey required')
65
+ const r = await fetch(`${opts.endpoint}/v1/keys`, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'content-type': 'application/json',
69
+ authorization: `Bearer ${opts.apiKey}`,
70
+ },
71
+ body: JSON.stringify(body),
72
+ })
73
+ return expectJson<KeyRegisterResponse>(r)
74
+ }
75
+
76
+ export async function publishPackage(
77
+ opts: ApiOptions,
78
+ data: Uint8Array,
79
+ ): Promise<PublishResult> {
80
+ if (!opts.apiKey) throw new Error('apiKey required')
81
+ const fd = new FormData()
82
+ fd.append('package', new Blob([data], { type: 'application/zip' }), 'package.mtx')
83
+ const r = await fetch(`${opts.endpoint}/v1/publish`, {
84
+ method: 'POST',
85
+ headers: { authorization: `Bearer ${opts.apiKey}` },
86
+ body: fd,
87
+ })
88
+ const text = await r.text()
89
+ let json: unknown
90
+ try {
91
+ json = JSON.parse(text)
92
+ } catch {
93
+ throw new Error(`unexpected response: ${text.slice(0, 200)}`)
94
+ }
95
+ return json as PublishResult
96
+ }
97
+
98
+ export async function yankVersion(
99
+ opts: ApiOptions,
100
+ id: string,
101
+ version: string,
102
+ ): Promise<void> {
103
+ if (!opts.apiKey) throw new Error('apiKey required')
104
+ const r = await fetch(`${opts.endpoint}/v1/extensions/${id}/yank/${version}`, {
105
+ method: 'POST',
106
+ headers: { authorization: `Bearer ${opts.apiKey}` },
107
+ })
108
+ await expectJson<{ ok: boolean }>(r)
109
+ }
110
+
111
+ export async function search(
112
+ opts: ApiOptions,
113
+ query: Record<string, string>,
114
+ ): Promise<SearchResult> {
115
+ const qs = new URLSearchParams(query).toString()
116
+ const r = await fetch(`${opts.endpoint}/v1/extensions?${qs}`)
117
+ return expectJson<SearchResult>(r)
118
+ }
@@ -0,0 +1,41 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ describe('config', () => {
7
+ let tmpDir: string
8
+
9
+ beforeEach(async () => {
10
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mtx-test-'))
11
+ process.env.MTX_HOME = tmpDir
12
+ })
13
+
14
+ afterEach(async () => {
15
+ delete process.env.MTX_HOME
16
+ await fs.rm(tmpDir, { recursive: true, force: true })
17
+ })
18
+
19
+ it('returns defaults when no config file exists', async () => {
20
+ const { loadConfig } = await import('./config')
21
+ const cfg = await loadConfig()
22
+ expect(cfg.endpoint).toBeTruthy()
23
+ expect(cfg.apiKey).toBeUndefined()
24
+ })
25
+
26
+ it('persists and reloads', async () => {
27
+ const { loadConfig, updateConfig } = await import('./config')
28
+ await updateConfig({ apiKey: 'mtx_test', authorId: 'gh-1', githubLogin: 'me' })
29
+ const cfg = await loadConfig()
30
+ expect(cfg.apiKey).toBe('mtx_test')
31
+ expect(cfg.authorId).toBe('gh-1')
32
+ })
33
+
34
+ it('writes config with mode 0600', async () => {
35
+ const { updateConfig, configPath } = await import('./config')
36
+ await updateConfig({ apiKey: 'mtx_test' })
37
+ const stat = await fs.stat(configPath())
38
+ const mode = stat.mode & 0o777
39
+ expect(mode).toBe(0o600)
40
+ })
41
+ })
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ export interface MtxConfig {
6
+ endpoint: string
7
+ authorId?: string
8
+ apiKey?: string
9
+ githubLogin?: string
10
+ activeKeyId?: string
11
+ }
12
+
13
+ export function configDir(): string {
14
+ return process.env.MTX_HOME ?? path.join(os.homedir(), '.mtx')
15
+ }
16
+
17
+ export function configPath(): string {
18
+ return path.join(configDir(), 'config.json')
19
+ }
20
+
21
+ export function defaultEndpoint(): string {
22
+ return process.env.MTX_ENDPOINT ?? 'https://marketplace.mterminal.dev'
23
+ }
24
+
25
+ export async function loadConfig(): Promise<MtxConfig> {
26
+ const file = configPath()
27
+ try {
28
+ const raw = await fs.readFile(file, 'utf8')
29
+ const parsed = JSON.parse(raw) as Partial<MtxConfig>
30
+ return {
31
+ endpoint: parsed.endpoint ?? defaultEndpoint(),
32
+ authorId: parsed.authorId,
33
+ apiKey: parsed.apiKey,
34
+ githubLogin: parsed.githubLogin,
35
+ activeKeyId: parsed.activeKeyId,
36
+ }
37
+ } catch {
38
+ return { endpoint: defaultEndpoint() }
39
+ }
40
+ }
41
+
42
+ export async function saveConfig(cfg: MtxConfig): Promise<void> {
43
+ const dir = configDir()
44
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
45
+ const file = configPath()
46
+ await fs.writeFile(file, JSON.stringify(cfg, null, 2), { mode: 0o600 })
47
+ }
48
+
49
+ export async function updateConfig(patch: Partial<MtxConfig>): Promise<MtxConfig> {
50
+ const cfg = await loadConfig()
51
+ const next: MtxConfig = { ...cfg, ...patch }
52
+ await saveConfig(next)
53
+ return next
54
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { configDir } from './config'
4
+
5
+ export function keysDir(): string {
6
+ return path.join(configDir(), 'keys')
7
+ }
8
+
9
+ export interface KeyPaths {
10
+ privPath: string
11
+ pubPath: string
12
+ }
13
+
14
+ export function keyPaths(keyId: string): KeyPaths {
15
+ const safe = keyId.replace(/[^a-zA-Z0-9_:.-]/g, '_')
16
+ return {
17
+ privPath: path.join(keysDir(), `${safe}.priv`),
18
+ pubPath: path.join(keysDir(), `${safe}.pub`),
19
+ }
20
+ }
21
+
22
+ export async function writeKey(
23
+ keyId: string,
24
+ priv: Uint8Array,
25
+ pubB64: string,
26
+ ): Promise<KeyPaths> {
27
+ const dir = keysDir()
28
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
29
+ const paths = keyPaths(keyId)
30
+ await fs.writeFile(paths.privPath, Buffer.from(priv).toString('base64'), {
31
+ mode: 0o600,
32
+ })
33
+ await fs.writeFile(paths.pubPath, pubB64, { mode: 0o600 })
34
+ return paths
35
+ }
36
+
37
+ export async function readPrivKey(keyId: string): Promise<Uint8Array> {
38
+ const { privPath } = keyPaths(keyId)
39
+ const raw = await fs.readFile(privPath, 'utf8')
40
+ return new Uint8Array(Buffer.from(raw.trim(), 'base64'))
41
+ }
42
+
43
+ export async function readPubKeyB64(keyId: string): Promise<string> {
44
+ const { pubPath } = keyPaths(keyId)
45
+ return (await fs.readFile(pubPath, 'utf8')).trim()
46
+ }