@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.
- package/LICENSE +21 -0
- package/bin/mtx.mjs +5 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +508 -0
- package/package.json +40 -0
- package/src/commands/init.ts +94 -0
- package/src/commands/keygen.ts +29 -0
- package/src/commands/login.ts +40 -0
- package/src/commands/pack.ts +53 -0
- package/src/commands/publish.ts +49 -0
- package/src/commands/whoami.ts +14 -0
- package/src/commands/yank.ts +13 -0
- package/src/index.ts +53 -0
- package/src/lib/api.test.ts +138 -0
- package/src/lib/api.ts +118 -0
- package/src/lib/config.test.ts +41 -0
- package/src/lib/config.ts +54 -0
- package/src/lib/keystore.ts +46 -0
- package/src/lib/pack.test.ts +104 -0
- package/src/lib/pack.ts +93 -0
- package/src/lib/sign.test.ts +32 -0
- package/src/lib/sign.ts +33 -0
- package/templates/minimal/README.md +17 -0
- package/templates/minimal/package.json +29 -0
- package/templates/minimal/src/main.ts +2 -0
- package/templates/minimal/src/renderer.tsx +2 -0
- package/templates/minimal/tsup.config.ts +19 -0
|
@@ -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
|
+
}
|