diff --git a/packages/examples/packages/bip32/snap.manifest.dev.json b/packages/examples/packages/bip32/snap.manifest.dev.json new file mode 100644 index 0000000000..733d82d22a --- /dev/null +++ b/packages/examples/packages/bip32/snap.manifest.dev.json @@ -0,0 +1,51 @@ +{ + "version": "2.3.0", + "description": "MetaMask example snap demonstrating the use of `snap_getBip32Entropy`.", + "proposedName": "BIP-32 Example Snap", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snaps.git" + }, + "source": { + "shasum": "yW/4a7WQ2dCmZHAaX6P2tihFF7azVPkpQ0iJlb/v61A=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "packageName": "@metamask/bip32-example-snap", + "registry": "https://registry.npmjs.org" + } + } + }, + "initialConnections": { + "http://localhost:9000": {} + }, + "initialPermissions": { + "endowment:rpc": { + "dapps": true, + "snaps": true + }, + "snap_dialog": {}, + "snap_getBip32Entropy": [ + { + "path": ["m", "44'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "44'", "0'"], + "curve": "ed25519" + }, + { + "path": ["m", "44'", "0'"], + "curve": "ed25519Bip32" + } + ], + "snap_getBip32PublicKey": [ + { + "path": ["m", "44'", "0'"], + "curve": "secp256k1" + } + ] + }, + "platformVersion": "10.3.0", + "manifestVersion": "0.1" +} diff --git a/packages/snaps-cli/src/builders.ts b/packages/snaps-cli/src/builders.ts index 694610ba8f..8445be40b1 100644 --- a/packages/snaps-cli/src/builders.ts +++ b/packages/snaps-cli/src/builders.ts @@ -36,6 +36,13 @@ const builders = { normalize: true, }, + manifest: { + alias: 'm', + describe: 'Path to snap.manifest.json file', + type: 'string', + normalize: true, + }, + port: { alias: 'p', describe: 'Local server port for testing', diff --git a/packages/snaps-cli/src/commands/manifest/implementation.test.ts b/packages/snaps-cli/src/commands/manifest/implementation.test.ts index cee6b4d91e..89c02d9b42 100644 --- a/packages/snaps-cli/src/commands/manifest/implementation.test.ts +++ b/packages/snaps-cli/src/commands/manifest/implementation.test.ts @@ -8,6 +8,7 @@ import { import type { SemVerVersion } from '@metamask/utils'; import normalFs from 'fs'; import ora from 'ora'; +import { join } from 'path'; import { manifest } from './implementation'; import type * as webpack from '../../webpack'; @@ -74,7 +75,7 @@ describe('manifest', () => { const spinner = ora(); const result = await manifest( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), false, undefined, spinner, @@ -97,7 +98,7 @@ describe('manifest', () => { const spinner = ora(); const result = await manifest( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), false, undefined, spinner, @@ -132,7 +133,7 @@ describe('manifest', () => { const spinner = ora(); const result = await manifest( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), true, undefined, spinner, @@ -155,7 +156,7 @@ describe('manifest', () => { const log = jest.spyOn(console, 'log').mockImplementation(); await fs.writeFile( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), JSON.stringify( getSnapManifest({ shasum: 'G/W5b2JZVv+epgNX9pkN63X6Lye9EJVJ4NLSgAw/afd=', diff --git a/packages/snaps-cli/src/commands/manifest/implementation.ts b/packages/snaps-cli/src/commands/manifest/implementation.ts index 573b090b43..dc07f27938 100644 --- a/packages/snaps-cli/src/commands/manifest/implementation.ts +++ b/packages/snaps-cli/src/commands/manifest/implementation.ts @@ -26,7 +26,12 @@ export async function manifest( exports?: string[], spinner?: Ora, ): Promise { - const { reports, updated } = await checkManifest(dirname(path), { + /* eslint-disable no-console */ + console.log('Checking manifest at path:', path); + console.log('Dirname:', dirname(path)); + /* eslint-enable no-console */ + + const { reports, updated } = await checkManifest(path, { exports, handlerEndowments, updateAndWriteManifest: write, diff --git a/packages/snaps-cli/src/commands/watch/index.test.ts b/packages/snaps-cli/src/commands/watch/index.test.ts index c2a225939f..2fd9e2c5ec 100644 --- a/packages/snaps-cli/src/commands/watch/index.test.ts +++ b/packages/snaps-cli/src/commands/watch/index.test.ts @@ -5,9 +5,10 @@ import type { YargsArgs } from '../../types/yargs'; jest.mock('./watch'); -const getMockArgv = () => { +const getMockArgv = (manifest?: string | undefined) => { return { context: { config: getMockConfig() }, + manifest, } as unknown as YargsArgs; }; @@ -16,4 +17,18 @@ describe('watch command', () => { await command.handler(getMockArgv()); expect(watchHandler).toHaveBeenCalled(); }); + + it('calls the `watchHandler` function with a custom manifest path', async () => { + await command.handler(getMockArgv('/custom.json')); + expect(watchHandler).toHaveBeenCalledWith( + expect.objectContaining({ + manifest: expect.objectContaining({ + path: expect.stringContaining('custom.json'), + }), + }), + { + port: undefined, + }, + ); + }); }); diff --git a/packages/snaps-cli/src/commands/watch/index.ts b/packages/snaps-cli/src/commands/watch/index.ts index 992a7a28e3..35db4c7357 100644 --- a/packages/snaps-cli/src/commands/watch/index.ts +++ b/packages/snaps-cli/src/commands/watch/index.ts @@ -1,3 +1,4 @@ +import { resolve } from 'path'; import type yargs from 'yargs'; import { watchHandler } from './watch'; @@ -8,12 +9,23 @@ const command = { command: ['watch', 'w'], desc: 'Build Snap on change', builder: (yarg: yargs.Argv) => { - yarg.option('port', builders.port); + yarg.option('port', builders.port).option('manifest', builders.manifest); }, - handler: async (argv: YargsArgs) => - watchHandler(argv.context.config, { + handler: async (argv: YargsArgs) => { + const configWithManifest = argv.manifest + ? { + ...argv.context.config, + manifest: { + ...argv.context.config.manifest, + path: resolve(process.cwd(), argv.manifest), + }, + } + : argv.context.config; + + return await watchHandler(configWithManifest, { port: argv.port, - }), + }); + }, }; export * from './implementation'; diff --git a/packages/snaps-cli/src/types/yargs.d.ts b/packages/snaps-cli/src/types/yargs.d.ts index b3ec09f966..367e581b5b 100644 --- a/packages/snaps-cli/src/types/yargs.d.ts +++ b/packages/snaps-cli/src/types/yargs.d.ts @@ -33,6 +33,7 @@ type YargsArgs = { dist: string; src: string; eval: boolean; + manifest?: string; outfileName: string; serve: boolean; directory?: string; diff --git a/packages/snaps-cli/src/webpack/server.test.ts b/packages/snaps-cli/src/webpack/server.test.ts index cf1cae39aa..aae2985c65 100644 --- a/packages/snaps-cli/src/webpack/server.test.ts +++ b/packages/snaps-cli/src/webpack/server.test.ts @@ -139,7 +139,8 @@ describe('getAllowedPaths', () => { describe('getServer', () => { beforeEach(async () => { - await fs.mkdir('/foo', { recursive: true }); + await fs.mkdir('/foo/dist', { recursive: true }); + await fs.writeFile('/foo/dist/bundle.js', 'console.log("Hello, world!");'); await fs.writeFile( '/foo/snap.manifest.json', JSON.stringify(getSnapManifest()), @@ -207,17 +208,53 @@ describe('getServer', () => { root: '/foo', port: 0, }, + output: { + path: '/foo/dist', + }, + manifest: { + path: '/foo/snap.manifest.json', + }, }); const server = getServer(config); const { port, close } = await server.listen(); const response = await fetch( - `http://localhost:${port}/snap.manifest.json?_=1731493314736`, + `http://localhost:${port}/dist/bundle.js?_=1731493314736`, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('console.log("Hello, world!");'); + + await close(); + }); + + it('responds with a custom manifest file', async () => { + const config = getMockConfig({ + input: 'src/index.js', + server: { + root: '/foo', + port: 0, + }, + manifest: { + path: '/foo/snap.manifest.dev.json', + }, + }); + + const server = getServer(config); + const { port, close } = await server.listen(); + + // Create a custom manifest file in the /foo/dist directory + const customManifest = getSnapManifest({ proposedName: 'Dev Snap' }); + await fs.writeFile( + '/foo/snap.manifest.dev.json', + JSON.stringify(customManifest), ); + const response = await fetch(`http://localhost:${port}/snap.manifest.json`); + expect(response.status).toBe(200); - expect(await response.text()).toBe(JSON.stringify(getSnapManifest())); + expect(await response.text()).toBe(JSON.stringify(customManifest)); await close(); }); @@ -229,6 +266,9 @@ describe('getServer', () => { root: '/foo', port: 0, }, + manifest: { + path: '/foo/snap.manifest.json', + }, }); const server = getServer(config); diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index 557ec5be5f..2cc806e78a 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -4,7 +4,7 @@ import type { Express, Request } from 'express'; import express, { static as expressStatic } from 'express'; import type { Server } from 'http'; import type { AddressInfo } from 'net'; -import { join, relative, resolve as resolvePath, sep, posix } from 'path'; +import { relative, resolve as resolvePath, sep, posix } from 'path'; import type { ProcessedConfig } from '../config'; @@ -91,8 +91,10 @@ export function getAllowedPaths( * `false` if it is not. */ async function isAllowedPath(request: Request, config: ProcessedConfig) { - const manifestPath = join(config.server.root, NpmSnapFileNames.Manifest); - const { result } = await readJsonFile(manifestPath); + const { result } = await readJsonFile( + resolvePath(config.server.root, config.manifest.path), + ); + const allowedPaths = getAllowedPaths(config, result); const path = request.path.slice(1); @@ -140,6 +142,28 @@ export function getServer( .catch(next); }); + // Serve the manifest file at the expected URL. + app.get('/snap.manifest.json', (_request, response, next) => { + response.sendFile( + resolvePath(config.server.root, config.manifest.path), + { + headers: { + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }, + }, + + // This is complicated to test, since this middleware is only called if + // the file exists in the first place. + /* istanbul ignore next */ + (error) => { + if (error) { + next(error); + } + }, + ); + }); + // Serve the static files. app.use( expressStatic(config.server.root, { diff --git a/packages/snaps-rollup-plugin/src/plugin.test.ts b/packages/snaps-rollup-plugin/src/plugin.test.ts index 1e86c6d4b2..eef5b9311a 100644 --- a/packages/snaps-rollup-plugin/src/plugin.test.ts +++ b/packages/snaps-rollup-plugin/src/plugin.test.ts @@ -237,7 +237,7 @@ describe('snaps', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { updateAndWriteManifest: true, sourceCode: expect.any(String), }); @@ -260,7 +260,7 @@ describe('snaps', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { updateAndWriteManifest: false, sourceCode: expect.any(String), }); diff --git a/packages/snaps-rollup-plugin/src/plugin.ts b/packages/snaps-rollup-plugin/src/plugin.ts index 2e932dd8a0..951c248db5 100644 --- a/packages/snaps-rollup-plugin/src/plugin.ts +++ b/packages/snaps-rollup-plugin/src/plugin.ts @@ -74,13 +74,10 @@ export default function snaps(options?: Partial): Plugin { } if (defaultOptions.manifestPath) { - const { reports } = await checkManifest( - pathUtils.dirname(defaultOptions.manifestPath), - { - updateAndWriteManifest: defaultOptions.writeManifest, - sourceCode: await fs.readFile(output.file, 'utf8'), - }, - ); + const { reports } = await checkManifest(defaultOptions.manifestPath, { + updateAndWriteManifest: defaultOptions.writeManifest, + sourceCode: await fs.readFile(output.file, 'utf8'), + }); const errorsUnfixed = reports .filter((report) => report.severity === 'error' && !report.wasFixed) diff --git a/packages/snaps-utils/src/manifest/manifest.test.ts b/packages/snaps-utils/src/manifest/manifest.test.ts index 0e2d0b3375..5ebc44410b 100644 --- a/packages/snaps-utils/src/manifest/manifest.test.ts +++ b/packages/snaps-utils/src/manifest/manifest.test.ts @@ -103,7 +103,7 @@ describe('checkManifest', () => { }); it('returns the status and warnings after processing', async () => { - const { updated, reports } = await checkManifest(BASE_PATH); + const { updated, reports } = await checkManifest(MANIFEST_PATH); expect(reports).toHaveLength(0); expect(updated).toBe(false); }); @@ -119,7 +119,7 @@ describe('checkManifest', () => { ), ); - const { files, updated, reports } = await checkManifest(BASE_PATH); + const { files, updated, reports } = await checkManifest(MANIFEST_PATH); const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); @@ -147,7 +147,7 @@ describe('checkManifest', () => { ), ); - const { files, updated, reports } = await checkManifest(BASE_PATH); + const { files, updated, reports } = await checkManifest(MANIFEST_PATH); const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); @@ -180,7 +180,7 @@ describe('checkManifest', () => { await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { files, updated, reports } = await checkManifest(BASE_PATH); + const { files, updated, reports } = await checkManifest(MANIFEST_PATH); const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); @@ -228,7 +228,7 @@ describe('checkManifest', () => { await fs.writeFile(PACKAGE_JSON_PATH, JSON.stringify(packageJson)); - const { updated, reports } = await checkManifest(BASE_PATH); + const { updated, reports } = await checkManifest(MANIFEST_PATH); const warnings = reports.filter(({ severity }) => severity === 'warning'); @@ -247,7 +247,7 @@ describe('checkManifest', () => { await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); const warnings = reports.filter(({ severity }) => severity === 'warning'); expect(warnings).toHaveLength(1); expect(warnings[0].message).toMatch( @@ -255,7 +255,7 @@ describe('checkManifest', () => { ); }); - it('returns a warning if manifest has with a non 1:1 ratio', async () => { + it('returns a warning if manifest has icon with a non 1:1 ratio', async () => { const manifest = getSnapManifest({ platformVersion: getPlatformVersion(), }); @@ -266,7 +266,7 @@ describe('checkManifest', () => { ); await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); const warnings = reports.filter(({ severity }) => severity === 'warning'); expect(warnings).toHaveLength(1); expect(warnings[0].message).toMatch( @@ -283,7 +283,7 @@ describe('checkManifest', () => { await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { reports } = await checkManifest(BASE_PATH, { + const { reports } = await checkManifest(MANIFEST_PATH, { updateAndWriteManifest: false, }); @@ -315,7 +315,7 @@ describe('checkManifest', () => { ), ); - await expect(checkManifest(BASE_PATH)).rejects.toThrow( + await expect(checkManifest(MANIFEST_PATH)).rejects.toThrow( "Failed to read snap files: ENOENT: no such file or directory, open '/snap/foo.json'", ); }); @@ -342,7 +342,7 @@ describe('checkManifest', () => { JSON.stringify(localizationFile), ); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); expect(reports.map(({ message }) => message)).toStrictEqual([ 'Failed to validate localization file "/snap/locales/en.json": At path: messages — Expected a value of type record, but received: "foo".', ]); @@ -370,7 +370,7 @@ describe('checkManifest', () => { JSON.stringify(localizationFile), ); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); expect(reports.map(({ message }) => message)).toStrictEqual([ 'Failed to localize Snap manifest: Failed to translate "{{ name }}": No translation found for "name" in "en" file.', ]); @@ -388,7 +388,7 @@ describe('checkManifest', () => { await fs.mkdir(join(BASE_PATH, 'locales')); await fs.writeFile(join(BASE_PATH, '/locales/en.json'), ','); - await expect(checkManifest(BASE_PATH)).rejects.toThrow( + await expect(checkManifest(MANIFEST_PATH)).rejects.toThrow( 'Failed to parse localization file "/snap/locales/en.json" as JSON.', ); }); @@ -401,7 +401,7 @@ describe('checkManifest', () => { throw new Error('foo'); }); - await expect(checkManifest(BASE_PATH)).rejects.toThrow( + await expect(checkManifest(MANIFEST_PATH)).rejects.toThrow( 'Failed to update "snap.manifest.json": foo', ); }); diff --git a/packages/snaps-utils/src/manifest/manifest.ts b/packages/snaps-utils/src/manifest/manifest.ts index 4866bf9d98..f8119d46b1 100644 --- a/packages/snaps-utils/src/manifest/manifest.ts +++ b/packages/snaps-utils/src/manifest/manifest.ts @@ -2,7 +2,7 @@ import { getErrorMessage } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; import { assert, isPlainObject } from '@metamask/utils'; import { promises as fs } from 'fs'; -import pathUtils from 'path'; +import pathUtils, { dirname } from 'path'; import type { SnapManifest } from './validation'; import type { ValidatorResults } from './validator'; @@ -92,7 +92,7 @@ export type WriteFileFunction = (path: string, data: string) => Promise; * the fixed version to disk if `writeManifest` is true. Throws if validation * fails. * - * @param basePath - The path to the folder with the manifest files. + * @param manifestPath - The path to the manifest file. * @param options - Additional options for the function. * @param options.sourceCode - The source code of the Snap. * @param options.writeFileFn - The function to use to write the manifest to @@ -111,7 +111,7 @@ export type WriteFileFunction = (path: string, data: string) => Promise; * were encountered during processing of the manifest files. */ export async function checkManifest( - basePath: string, + manifestPath: string, { updateAndWriteManifest = true, sourceCode, @@ -121,7 +121,7 @@ export async function checkManifest( watchMode = false, }: CheckManifestOptions = {}, ): Promise { - const manifestPath = pathUtils.join(basePath, NpmSnapFileNames.Manifest); + const basePath = dirname(manifestPath); const manifestFile = await readJsonFile(manifestPath); const unvalidatedManifest = manifestFile.result; @@ -188,7 +188,7 @@ export async function checkManifest( try { await writeFileFn( - pathUtils.join(basePath, NpmSnapFileNames.Manifest), + manifestPath, manifestResults.files.manifest.toString(), ); } catch (error) { diff --git a/packages/snaps-webpack-plugin/src/plugin.test.ts b/packages/snaps-webpack-plugin/src/plugin.test.ts index 3062638e1c..f7269f49c2 100644 --- a/packages/snaps-webpack-plugin/src/plugin.test.ts +++ b/packages/snaps-webpack-plugin/src/plugin.test.ts @@ -276,7 +276,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { exports: undefined, handlerEndowments, updateAndWriteManifest: true, @@ -320,7 +320,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(checkManifestMock).toHaveBeenCalledTimes(1); - expect(checkManifestMock).toHaveBeenCalledWith('/', { + expect(checkManifestMock).toHaveBeenCalledWith('/snap.manifest.json', { exports: ['foo'], handlerEndowments, updateAndWriteManifest: true, @@ -347,7 +347,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { exports: undefined, handlerEndowments, updateAndWriteManifest: false, @@ -377,7 +377,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { exports: undefined, handlerEndowments, updateAndWriteManifest: false, diff --git a/packages/snaps-webpack-plugin/src/plugin.ts b/packages/snaps-webpack-plugin/src/plugin.ts index 0f689c724e..99b08db4a5 100644 --- a/packages/snaps-webpack-plugin/src/plugin.ts +++ b/packages/snaps-webpack-plugin/src/plugin.ts @@ -187,27 +187,24 @@ export default class SnapsWebpackPlugin { } if (this.options.manifestPath) { - const { reports } = await checkManifest( - pathUtils.dirname(this.options.manifestPath), - { - updateAndWriteManifest: this.options.writeManifest, - sourceCode: bundleContent, - exports, - handlerEndowments, - watchMode: compiler.watchMode, - writeFileFn: async (path, data) => { - assert( - compiler.outputFileSystem, - 'Expected compiler to have an output file system.', - ); - return writeManifest( - path, - data, - promisify(compiler.outputFileSystem.writeFile), - ); - }, + const { reports } = await checkManifest(this.options.manifestPath, { + updateAndWriteManifest: this.options.writeManifest, + sourceCode: bundleContent, + exports, + handlerEndowments, + watchMode: compiler.watchMode, + writeFileFn: async (path, data) => { + assert( + compiler.outputFileSystem, + 'Expected compiler to have an output file system.', + ); + return writeManifest( + path, + data, + promisify(compiler.outputFileSystem.writeFile), + ); }, - ); + }); const errors = reports .filter((report) => report.severity === 'error' && !report.wasFixed)