@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,104 @@
|
|
|
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
|
+
import { unzipSync } from 'fflate'
|
|
6
|
+
import { pack } from './pack'
|
|
7
|
+
import { generateKeyPair } from './sign'
|
|
8
|
+
|
|
9
|
+
describe('pack', () => {
|
|
10
|
+
let cwd: string
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'mtx-pack-'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(cwd, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
async function scaffold(extras?: { capabilities?: string[]; allowedNetworkDomains?: string[] }) {
|
|
21
|
+
const pkg = {
|
|
22
|
+
name: 'mterminal-plugin-pack-demo',
|
|
23
|
+
version: '0.2.0',
|
|
24
|
+
main: 'dist/main.cjs',
|
|
25
|
+
engines: { 'mterminal-api': '^1.0.0' },
|
|
26
|
+
mterminal: {
|
|
27
|
+
id: 'pack-demo',
|
|
28
|
+
displayName: 'pack demo',
|
|
29
|
+
category: 'other',
|
|
30
|
+
publisher: { authorId: '', keyId: '' },
|
|
31
|
+
activationEvents: ['onStartupFinished'],
|
|
32
|
+
capabilities: extras?.capabilities ?? ['clipboard'],
|
|
33
|
+
allowedNetworkDomains: extras?.allowedNetworkDomains ?? [],
|
|
34
|
+
contributes: {},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
await fs.writeFile(path.join(cwd, 'package.json'), JSON.stringify(pkg, null, 2))
|
|
38
|
+
await fs.mkdir(path.join(cwd, 'dist'), { recursive: true })
|
|
39
|
+
await fs.writeFile(
|
|
40
|
+
path.join(cwd, 'dist', 'main.cjs'),
|
|
41
|
+
'module.exports = { activate(){}, deactivate(){} }\n',
|
|
42
|
+
)
|
|
43
|
+
await fs.writeFile(path.join(cwd, 'README.md'), '# pack-demo\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('packs a working extension and rewrites publisher fields', async () => {
|
|
47
|
+
await scaffold()
|
|
48
|
+
const { priv } = await generateKeyPair()
|
|
49
|
+
const result = await pack({
|
|
50
|
+
cwd,
|
|
51
|
+
authorId: 'gh-99',
|
|
52
|
+
keyId: 'gh-99:key1',
|
|
53
|
+
privKey: priv,
|
|
54
|
+
})
|
|
55
|
+
expect(result.manifestId).toBe('pack-demo')
|
|
56
|
+
expect(result.version).toBe('0.2.0')
|
|
57
|
+
|
|
58
|
+
const entries = unzipSync(result.buf)
|
|
59
|
+
expect(Object.keys(entries).sort()).toEqual([
|
|
60
|
+
'README.md',
|
|
61
|
+
'dist/main.cjs',
|
|
62
|
+
'package.json',
|
|
63
|
+
'signature.sig',
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
const pkg = JSON.parse(new TextDecoder().decode(entries['package.json']!))
|
|
67
|
+
expect(pkg.mterminal.publisher.authorId).toBe('gh-99')
|
|
68
|
+
expect(pkg.mterminal.publisher.keyId).toBe('gh-99:key1')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('refuses to pack an invalid manifest', async () => {
|
|
72
|
+
const pkg = {
|
|
73
|
+
name: 'mterminal-plugin-bad',
|
|
74
|
+
version: 'not-a-semver',
|
|
75
|
+
main: 'dist/main.cjs',
|
|
76
|
+
engines: { 'mterminal-api': '^1.0.0' },
|
|
77
|
+
mterminal: {
|
|
78
|
+
id: 'BadId',
|
|
79
|
+
publisher: { authorId: '', keyId: '' },
|
|
80
|
+
activationEvents: ['onStartupFinished'],
|
|
81
|
+
capabilities: [],
|
|
82
|
+
contributes: {},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
await fs.writeFile(path.join(cwd, 'package.json'), JSON.stringify(pkg))
|
|
86
|
+
await fs.mkdir(path.join(cwd, 'dist'), { recursive: true })
|
|
87
|
+
await fs.writeFile(path.join(cwd, 'dist', 'main.cjs'), 'module.exports = {}\n')
|
|
88
|
+
|
|
89
|
+
const { priv } = await generateKeyPair()
|
|
90
|
+
await expect(
|
|
91
|
+
pack({ cwd, authorId: 'gh-1', keyId: 'gh-1:key1', privKey: priv }),
|
|
92
|
+
).rejects.toThrow(/manifest invalid/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('produces signature.sig that matches the deterministic hash', async () => {
|
|
96
|
+
await scaffold()
|
|
97
|
+
const { priv } = await generateKeyPair()
|
|
98
|
+
const a = await pack({ cwd, authorId: 'gh-1', keyId: 'gh-1:key1', privKey: priv })
|
|
99
|
+
const b = await pack({ cwd, authorId: 'gh-1', keyId: 'gh-1:key1', privKey: priv })
|
|
100
|
+
const sigA = unzipSync(a.buf)['signature.sig']!
|
|
101
|
+
const sigB = unzipSync(b.buf)['signature.sig']!
|
|
102
|
+
expect(new TextDecoder().decode(sigA)).toBe(new TextDecoder().decode(sigB))
|
|
103
|
+
})
|
|
104
|
+
})
|
package/src/lib/pack.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { zipSync, strToU8 } from 'fflate'
|
|
4
|
+
import { signEntries, type PackEntry } from './sign'
|
|
5
|
+
import { validateManifest } from '@mterminal/manifest-validator'
|
|
6
|
+
|
|
7
|
+
const INCLUDED_DIRS = ['dist', 'themes']
|
|
8
|
+
const INCLUDED_FILES = ['package.json', 'README.md', 'readme.md', 'icon.png']
|
|
9
|
+
|
|
10
|
+
async function walk(root: string, prefix = ''): Promise<PackEntry[]> {
|
|
11
|
+
const out: PackEntry[] = []
|
|
12
|
+
let stat: { isDirectory(): boolean; isFile(): boolean }
|
|
13
|
+
try {
|
|
14
|
+
stat = await fs.stat(root)
|
|
15
|
+
} catch {
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
if (!stat.isDirectory()) return out
|
|
19
|
+
const entries = await fs.readdir(root, { withFileTypes: true })
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
const abs = path.join(root, e.name)
|
|
22
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name
|
|
23
|
+
if (e.isDirectory()) {
|
|
24
|
+
out.push(...(await walk(abs, rel)))
|
|
25
|
+
} else if (e.isFile()) {
|
|
26
|
+
const content = await fs.readFile(abs)
|
|
27
|
+
out.push({ path: rel, content: new Uint8Array(content) })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PackOptions {
|
|
34
|
+
cwd: string
|
|
35
|
+
authorId: string
|
|
36
|
+
keyId: string
|
|
37
|
+
privKey: Uint8Array
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PackResult {
|
|
41
|
+
buf: Uint8Array
|
|
42
|
+
manifestId: string
|
|
43
|
+
version: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function pack(opts: PackOptions): Promise<PackResult> {
|
|
47
|
+
const pkgPath = path.join(opts.cwd, 'package.json')
|
|
48
|
+
const raw = await fs.readFile(pkgPath, 'utf8')
|
|
49
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>
|
|
50
|
+
|
|
51
|
+
const mt = (pkg.mterminal ?? {}) as Record<string, unknown>
|
|
52
|
+
const publisher = (mt.publisher ?? {}) as Record<string, unknown>
|
|
53
|
+
if (publisher.authorId !== opts.authorId || publisher.keyId !== opts.keyId) {
|
|
54
|
+
mt.publisher = { authorId: opts.authorId, keyId: opts.keyId }
|
|
55
|
+
pkg.mterminal = mt
|
|
56
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const validation = validateManifest(pkg)
|
|
60
|
+
if (!validation.ok) {
|
|
61
|
+
throw new Error(`manifest invalid:\n - ${validation.errors.join('\n - ')}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entries: PackEntry[] = []
|
|
65
|
+
entries.push({
|
|
66
|
+
path: 'package.json',
|
|
67
|
+
content: new Uint8Array(await fs.readFile(pkgPath)),
|
|
68
|
+
})
|
|
69
|
+
for (const dir of INCLUDED_DIRS) {
|
|
70
|
+
entries.push(...(await walk(path.join(opts.cwd, dir), dir)))
|
|
71
|
+
}
|
|
72
|
+
for (const file of INCLUDED_FILES) {
|
|
73
|
+
if (file === 'package.json') continue
|
|
74
|
+
const abs = path.join(opts.cwd, file)
|
|
75
|
+
try {
|
|
76
|
+
const buf = await fs.readFile(abs)
|
|
77
|
+
entries.push({ path: file, content: new Uint8Array(buf) })
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sigB64 = await signEntries(entries, opts.privKey)
|
|
82
|
+
entries.push({ path: 'signature.sig', content: strToU8(sigB64) })
|
|
83
|
+
|
|
84
|
+
const zipMap: Record<string, Uint8Array> = {}
|
|
85
|
+
for (const e of entries) zipMap[e.path] = e.content
|
|
86
|
+
const buf = zipSync(zipMap)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
buf,
|
|
90
|
+
manifestId: validation.manifest.id,
|
|
91
|
+
version: validation.manifest.version,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { deterministicHashHex, generateKeyPair, signEntries, type PackEntry } from './sign'
|
|
3
|
+
|
|
4
|
+
describe('deterministicHashHex', () => {
|
|
5
|
+
it('produces stable hash regardless of entry order', () => {
|
|
6
|
+
const a: PackEntry[] = [
|
|
7
|
+
{ path: 'a.txt', content: new Uint8Array([1, 2, 3]) },
|
|
8
|
+
{ path: 'b.txt', content: new Uint8Array([4, 5, 6]) },
|
|
9
|
+
]
|
|
10
|
+
const b: PackEntry[] = [...a].reverse()
|
|
11
|
+
expect(deterministicHashHex(a)).toBe(deterministicHashHex(b))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('excludes signature.sig from hash', () => {
|
|
15
|
+
const without: PackEntry[] = [{ path: 'a.txt', content: new Uint8Array([1]) }]
|
|
16
|
+
const withSig: PackEntry[] = [
|
|
17
|
+
...without,
|
|
18
|
+
{ path: 'signature.sig', content: new Uint8Array([99]) },
|
|
19
|
+
]
|
|
20
|
+
expect(deterministicHashHex(without)).toBe(deterministicHashHex(withSig))
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('signEntries / generateKeyPair', () => {
|
|
25
|
+
it('produces a 64-byte ed25519 signature', async () => {
|
|
26
|
+
const { priv } = await generateKeyPair()
|
|
27
|
+
const entries: PackEntry[] = [{ path: 'a.txt', content: new Uint8Array([1, 2]) }]
|
|
28
|
+
const sigB64 = await signEntries(entries, priv)
|
|
29
|
+
const sig = Buffer.from(sigB64, 'base64')
|
|
30
|
+
expect(sig.length).toBe(64)
|
|
31
|
+
})
|
|
32
|
+
})
|
package/src/lib/sign.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as ed from '@noble/ed25519'
|
|
2
|
+
import { sha256, sha512 } from '@noble/hashes/sha2'
|
|
3
|
+
import { bytesToHex } from '@noble/hashes/utils'
|
|
4
|
+
|
|
5
|
+
ed.etc.sha512Sync = (...m: Uint8Array[]) => sha512(ed.etc.concatBytes(...m))
|
|
6
|
+
|
|
7
|
+
export interface PackEntry {
|
|
8
|
+
path: string
|
|
9
|
+
content: Uint8Array
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function deterministicHashHex(entries: PackEntry[]): string {
|
|
13
|
+
const sorted = entries
|
|
14
|
+
.filter((e) => e.path !== 'signature.sig')
|
|
15
|
+
.map((e) => ({ path: e.path, hash: bytesToHex(sha256(e.content)) }))
|
|
16
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
17
|
+
|
|
18
|
+
const lines = sorted.map((e) => `${e.path} ${e.hash}\n`).join('')
|
|
19
|
+
return bytesToHex(sha256(new TextEncoder().encode(lines)))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function signEntries(entries: PackEntry[], privKey: Uint8Array): Promise<string> {
|
|
23
|
+
const hashHex = deterministicHashHex(entries)
|
|
24
|
+
const message = new TextEncoder().encode(hashHex)
|
|
25
|
+
const sig = await ed.signAsync(message, privKey)
|
|
26
|
+
return Buffer.from(sig).toString('base64')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function generateKeyPair(): Promise<{ priv: Uint8Array; pubB64: string }> {
|
|
30
|
+
const priv = ed.utils.randomPrivateKey()
|
|
31
|
+
const pub = await ed.getPublicKeyAsync(priv)
|
|
32
|
+
return { priv, pubB64: Buffer.from(pub).toString('base64') }
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mterminal-plugin-EXT_ID",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "an mTerminal extension",
|
|
5
|
+
"main": "dist/main.cjs",
|
|
6
|
+
"renderer": "dist/renderer.mjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"mterminal-api": "^1.0.0"
|
|
12
|
+
},
|
|
13
|
+
"mterminal": {
|
|
14
|
+
"id": "EXT_ID",
|
|
15
|
+
"displayName": "EXT_ID",
|
|
16
|
+
"category": "other",
|
|
17
|
+
"publisher": {
|
|
18
|
+
"authorId": "",
|
|
19
|
+
"keyId": ""
|
|
20
|
+
},
|
|
21
|
+
"activationEvents": ["onStartupFinished"],
|
|
22
|
+
"capabilities": [],
|
|
23
|
+
"contributes": {}
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"tsup": "^8.3.5",
|
|
27
|
+
"typescript": "^5.6.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup'
|
|
2
|
+
|
|
3
|
+
export default defineConfig([
|
|
4
|
+
{
|
|
5
|
+
entry: ['src/main.ts'],
|
|
6
|
+
format: ['cjs'],
|
|
7
|
+
outDir: 'dist',
|
|
8
|
+
outExtension: () => ({ js: '.cjs' }),
|
|
9
|
+
target: 'node22',
|
|
10
|
+
clean: true,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
entry: ['src/renderer.tsx'],
|
|
14
|
+
format: ['esm'],
|
|
15
|
+
outDir: 'dist',
|
|
16
|
+
outExtension: () => ({ js: '.mjs' }),
|
|
17
|
+
target: 'es2022',
|
|
18
|
+
},
|
|
19
|
+
])
|