diff --git a/assets/.editorconfig b/assets/.editorconfig new file mode 100644 index 0000000..16ab988 --- /dev/null +++ b/assets/.editorconfig @@ -0,0 +1,27 @@ +# http://editorconfig.org/ + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[*.{js,jsx,ts,tsx,json,html}] +indent_style = space +indent_size = 4 + +[webapp/package.json] +indent_size = 2 + +[{Makefile,*.mk}] +indent_style = tab + +[*.md] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = false diff --git a/assets/.github/workflows/ci.yml b/assets/.github/workflows/ci.yml new file mode 100644 index 0000000..f9040f6 --- /dev/null +++ b/assets/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci +on: + schedule: + - cron: "0 0 * * *" + push: + branches: + - main + tags: + - "v*" + pull_request: + +permissions: + contents: read + +env: + IS_CI: true + +jobs: + plugin-ci: + uses: mattermost/actions-workflows/.github/workflows/plugin-ci.yml + secrets: inherit + with: + mage-version: v1.15.0 diff --git a/assets/.golangci.yml b/assets/.golangci.yml new file mode 100644 index 0000000..5682ada --- /dev/null +++ b/assets/.golangci.yml @@ -0,0 +1,48 @@ +run: + timeout: 5m + modules-download-mode: readonly + +linters-settings: + gofmt: + simplify: true + goimports: + local-prefixes: github.com/mattermost/mattermost-starter-template + govet: + check-shadowing: true + enable-all: true + disable: + - fieldalignment + misspell: + locale: US + +linters: + disable-all: true + enable: + - bodyclose + - errcheck + - gocritic + - gofmt + - goimports + - gosec + - gosimple + - govet + - ineffassign + - misspell + - nakedret + - revive + - staticcheck + - stylecheck + - typecheck + - unconvert + - unused + - whitespace + +issues: + exclude-rules: + - path: server/configuration.go + linters: + - unused + - path: _test\.go + linters: + - bodyclose + - scopelint # https://github.com/kyoh86/scopelint/issues/4 diff --git a/assets/webapp/.npmrc b/assets/webapp/.npmrc new file mode 100644 index 0000000..cffe8cd --- /dev/null +++ b/assets/webapp/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/assets/webapp/babel.config.js b/assets/webapp/babel.config.js new file mode 100644 index 0000000..2aa81bf --- /dev/null +++ b/assets/webapp/babel.config.js @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +const config = { + presets: [ + ['@babel/preset-env', { + targets: { + chrome: 66, + firefox: 60, + edge: 42, + safari: 12, + }, + modules: false, + corejs: 3, + debug: false, + useBuiltIns: 'usage', + shippedProposals: true, + }], + ['@babel/preset-react', { + useBuiltIns: true, + }], + ['@babel/typescript', { + allExtensions: true, + isTSX: true, + }], + ['@emotion/babel-preset-css-prop'], + ], + plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-syntax-dynamic-import', + '@babel/proposal-object-rest-spread', + '@babel/plugin-proposal-optional-chaining', + 'babel-plugin-typescript-to-proptypes', + ], +}; + +// Jest needs module transformation +config.env = { + test: { + presets: config.presets, + plugins: config.plugins, + }, +}; +config.env.test.presets[0][1].modules = 'auto'; + +module.exports = config; diff --git a/assets/webapp/webpack.config.js b/assets/webapp/webpack.config.js new file mode 100644 index 0000000..a3d9032 --- /dev/null +++ b/assets/webapp/webpack.config.js @@ -0,0 +1,115 @@ +const exec = require('child_process').exec; + +const path = require('path'); + +const webpack = require('webpack'); + +const PLUGIN_ID = require('../plugin.json').id; + +const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env +const isDev = NPM_TARGET === 'debug' || NPM_TARGET === 'debug:watch'; + +const plugins = [ + new webpack.ProvidePlugin({ + process: 'process/browser', + }), +]; +if (NPM_TARGET === 'build:watch' || NPM_TARGET === 'debug:watch') { + plugins.push({ + apply: (compiler) => { + compiler.hooks.watchRun.tap('WatchStartPlugin', () => { + // eslint-disable-next-line no-console + console.log('Change detected. Rebuilding webapp.'); + }); + compiler.hooks.afterEmit.tap('AfterEmitPlugin', () => { + exec('cd .. && make deploy-from-watch', (err, stdout, stderr) => { + if (stdout) { + process.stdout.write(stdout); + } + if (stderr) { + process.stderr.write(stderr); + } + }); + }); + }, + }); +} + +const config = { + entry: [ + './src/index.tsx', + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + modules: [ + 'src', + 'node_modules', + path.resolve(__dirname), + ], + extensions: ['*', '.js', '.jsx', '.ts', '.tsx'], + }, + module: { + rules: [ + { + test: /\.(js|jsx|ts|tsx)$/, + + //exclude: /node_modules\/(?!(mattermost-webapp|@mattermost)\/).*/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + cacheDirectory: true, + + // Babel configuration is in babel.config.js because jest requires it to be there. + }, + }, + }, + { + test: /\.(scss|css)$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + options: { + sassOptions: { + includePaths: ['node_modules/compass-mixins/lib', 'sass'], + }, + }, + }, + ], + }, + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + ], + }, + externals: { + react: 'React', + 'react-dom': 'ReactDOM', + redux: 'Redux', + 'react-redux': 'ReactRedux', + 'prop-types': 'PropTypes', + 'react-bootstrap': 'ReactBootstrap', + 'react-router-dom': 'ReactRouterDom', + }, + output: { + devtoolNamespace: PLUGIN_ID, + path: path.join(__dirname, '/dist'), + publicPath: '/', + filename: 'main.js', + }, + mode: (isDev) ? 'eval-source-map' : 'production', + plugins, +}; + +if (isDev) { + Object.assign(config, {devtool: 'eval-source-map'}); +} + +module.exports = config; diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 552c2c4..02a30f5 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -49,6 +49,8 @@ func runCommand(command string, args []string, pluginPath string) error { return runDisableCommand(args, pluginPath) case "reset": return runResetCommand(args, pluginPath) + case "updateassets": + return runUpdateAssetsCommand(args, pluginPath) case "help": showUsage() @@ -82,6 +84,10 @@ func runResetCommand(args []string, pluginPath string) error { return pluginctl.RunResetCommand(args, pluginPath) } +func runUpdateAssetsCommand(args []string, pluginPath string) error { + return pluginctl.RunUpdateAssetsCommand(args, pluginPath) +} + func showUsage() { fmt.Printf(`pluginctl - Mattermost Plugin Development CLI @@ -96,6 +102,7 @@ Commands: enable Enable plugin from current directory in Mattermost server disable Disable plugin from current directory in Mattermost server reset Reset plugin from current directory (disable then enable) + updateassets Update plugin files from embedded assets help Show this help message version Show version information @@ -105,6 +112,7 @@ Examples: pluginctl enable # Enable plugin from current directory pluginctl disable # Disable plugin from current directory pluginctl reset # Reset plugin from current directory (disable then enable) + pluginctl updateassets # Update plugin files from embedded assets export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info # Show info using environment variable pluginctl version # Show version information diff --git a/updateassets.go b/updateassets.go new file mode 100644 index 0000000..f9e3d03 --- /dev/null +++ b/updateassets.go @@ -0,0 +1,98 @@ +package pluginctl + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +//go:embed assets/**/* +var assetsFS embed.FS + +func RunUpdateAssetsCommand(args []string, pluginPath string) error { + if len(args) > 0 { + return fmt.Errorf("updateassets command does not accept arguments") + } + + fmt.Printf("Updating assets in plugin directory: %s\n", pluginPath) + + // Load plugin manifest to check for webapp code + manifest, err := LoadPluginManifestFromPath(pluginPath) + if err != nil { + return fmt.Errorf("failed to load plugin manifest: %w", err) + } + + // Check if the plugin has webapp code according to manifest + hasWebapp := HasWebappCode(manifest) + + // Counter for updated files + var updatedCount int + + // Walk through the embedded assets + err = fs.WalkDir(assetsFS, "assets", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root assets directory + if path == "assets" { + return nil + } + + // Remove the "assets/" prefix to get the relative path + relativePath := path[7:] // len("assets/") = 7 + + // Skip webapp assets if plugin doesn't have webapp code + if !hasWebapp && strings.HasPrefix(relativePath, "webapp") { + return nil + } + + targetPath := filepath.Join(pluginPath, relativePath) + + if d.IsDir() { + // Create directory if it doesn't exist + if err := os.MkdirAll(targetPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", targetPath, err) + } + } else { + // Read file content from embedded FS + content, err := assetsFS.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read embedded file %s: %w", path, err) + } + + // Check if target file exists and compare content + existingContent, err := os.ReadFile(targetPath) + if err == nil && bytes.Equal(existingContent, content) { + // File exists and content is identical, skip update + return nil + } + + // Create parent directory if it doesn't exist + parentDir := filepath.Dir(targetPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err) + } + + // Write file to target location + if err := os.WriteFile(targetPath, content, 0644); err != nil { + return fmt.Errorf("failed to write file %s: %w", targetPath, err) + } + fmt.Printf("Updated file: %s\n", relativePath) + updatedCount++ + } + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to update assets: %w", err) + } + + fmt.Printf("Assets updated successfully! (%d files updated)\n", updatedCount) + return nil +}