@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,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
+ })
@@ -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
+ })
@@ -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,17 @@
1
+ # EXT_ID
2
+
3
+ An mTerminal extension scaffolded with `mtx init`.
4
+
5
+ ## develop
6
+
7
+ ```bash
8
+ npm i
9
+ npm run build
10
+ ```
11
+
12
+ ## publish
13
+
14
+ ```bash
15
+ mtx pack
16
+ mtx publish
17
+ ```
@@ -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,2 @@
1
+ export function activate() {}
2
+ export function deactivate() {}
@@ -0,0 +1,2 @@
1
+ export function activate() {}
2
+ export function deactivate() {}
@@ -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
+ ])