plugins_release_upload.js

// @ts-check

/**
 * @file plugin/upload.js
 * !!! This module uses 'plink' and 'pscp' from the PuTTY package: https://www.putty.org
 * !!! For first time build on fresh os install:
 * !!!   - create the environment variables WPW_APP3_SSH_AUTH_*
 * !!!   - run a plink command manually to generate and trust the fingerprints:
 * !!!       plink -ssh -pw <PWD> smeesseman@app3.spmeesseman.com "echo hello"
 * !!!       plink -ssh -batch -pw <PWD> ubuntu@app3.spmhome.io "echo hello"
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *//** */

const WpwPlugin = require("../base");
const WpwRegex = require("../../utils/regex");
const { rm, readdir, rename } = require("fs/promises");
const { getUniqBuildKey } = require("../../utils/utils");
const { copyFileAsync, createDirAsync, existsSync, joinPath, resolvePath } = require("@spmhome/cmn-utils");


/**
 * @augments WpwPlugin
 */
class WpwUploadPlugin extends WpwPlugin
{
    /**
     * @param {WpwPluginOptions} options Plugin options to be applied
     */
	constructor(options)
    {
        super(options);
        this.buildOptions = /** @type {WpwBuildOptionsPluginConfig<"upload">} */(this.buildOptions);
    }


	/**
     * @override
     */
	static create = WpwUploadPlugin.wrap.bind(this);


    /**
     * @override
     * @returns {WpwPluginTapOptions<any, any, boolean>}
     */
    onApply()
    {
        return {
            uploadAssets: {
                async: true,
                hook: "afterEmit",
                callback: this.uploadAssets.bind(this)
            },
            cleanupAssetUpload: {
                async: true,
                hook: "done",
                callback: this.cleanup.bind(this)
            }
        };
    }


    /**
     * @private
     * @param {WebpackStats} _stats
     */
    async cleanup(_stats)
    {
        const tmpUploadPath = joinPath(this.build.paths.temp, this.build.mode);
        try
        {   if (existsSync(tmpUploadPath))
            {
                const tmpFiles = await readdir(tmpUploadPath);
                if (tmpFiles.length > 0)
                {
                    await rm(tmpUploadPath, { recursive: true, force: true });
                }
                this.build.logger.write("upload plugin cleanup completed");
        }   }
        catch (e)
        {   this.addMessage({
                exception: e,
				capture: this.cleanup,
                code: this.MsgCode.ERROR_GENERAL,
                message: "exception while cleaning upload-from path"
            });
        }
    };


    /**
     * @public
     * @param {WebpackCompilation} compilation
     */
    async uploadAssets(compilation)
    {   //
        // `The lBasePath` variable is a temp directory that we will create in the in
        // the OS/env temp dir.  We will move only files that have changed content there,
        // and perform only one upload when all builds have completed.
        //
        const build = this.build,
              logger = build.logger,
              ep = this.buildOptions.endpoint,
              toUploadPath = joinPath(build.paths.temp, getUniqBuildKey(build, true));

        logger.write("upload resource files", 1);

        // const host = process.env.WPBUILD_APP3_SSH_UPLOAD_HOST,
        //       user = process.env.WPBUILD_APP3_SSH_UPLOAD_USER,
        //       bo.path = process.env.WPBUILD_APP3_SSH_UPLOAD_PATH,
        //       sshAuth = process.env.WPBUILD_APP3_SSH_UPLOAD_AUTH,
        //       sshAuthFlag = process.env.WPBUILD_APP3_SSH_UPLOAD_FLAG;

        if (!existsSync(toUploadPath)) {
            await createDirAsync(toUploadPath);
        }

        if (this.buildOptions.paths)
        {
            for (const p of this._arr_.asArray(this.buildOptions.paths))
            {
                const pathAbs = resolvePath(build.getContextPath(), p);
                if (this._fs_.isDir(pathAbs))
                {
                    await this._fs_.copyDirAsync(pathAbs, toUploadPath, [], true);
                }
                else {
                    await this._fs_.copyFileAsync(pathAbs, joinPath(toUploadPath, this._path_.basename(p)));
                }
            }
        }
        else
        {
            for (const chunk of Array.from(compilation.chunks)
                .filter(c => c.canBeInitial() && c.name && !WpwRegex.TestsChunk.test(c.name)))
            {
                for (const file of Array.from(chunk.files))
                {
                    const asset = compilation.getAsset(file);
                    if (asset && chunk.name && (build.global.hash.next[chunk.name] !== build.global.hash.current[chunk.name] ||
                        !build.global.hash.previous[chunk.name]))
                    {
                        const distPath = this.compiler.outputPath || this.compilation.outputOptions.path || build.getDistPath();
                        logger.value("   queue asset for upload", logger.tag(file), 2);
                        logger.value("      asset info", asset.info, 4);
                        await copyFileAsync(joinPath(distPath, file), joinPath(toUploadPath, file));
                        if (asset.info.related?.sourceMap)
                        {
                            const sourceMapFile = asset.info.related.sourceMap.toString();
                            logger.value("   queue sourcemap for upload", logger.tag(sourceMapFile), 2);
                            if (build.mode === "production") {
                                logger.value("   remove production sourcemap from distribution", sourceMapFile, 3);
                                await rename(joinPath(distPath, sourceMapFile), joinPath(toUploadPath, sourceMapFile));
                            }
                            else {
                                await copyFileAsync(joinPath(distPath, sourceMapFile), joinPath(toUploadPath, sourceMapFile));
                            }
                        }
                    }
                    else /* istanbul ignore else */if (asset) {
                        logger.value("   unchanged, skip asset upload", logger.tag(file), 2);
                    }
                    else {
                        logger.value("   unknown error, skip asset upload", logger.tag(file), 2);
                    }
                }
            }
        }

        const filesToUpload = await readdir(toUploadPath);
        if (filesToUpload.length === 0)
        {
            logger.write("no assets to upload", 1, "");
            return;
        }

        const name = build.pkgJson.scopedName.name;
        const plinkCmds = [
            `mkdir -p ${ep.path}/product`,
            `mkdir -p ${ep.path}/product/${name}`,
            `mkdir -p ${ep.path}/product/${name}/doc`,
            `mkdir ${ep.path}/${name}/v${build.pkgJson.version}`
            // `mkdir ${ep.path}/${name}/v${build.pkgJson.version}/${build.mode}`,
            // `rm -f ${ep.path}/${name}/v${build.pkgJson.version}/${build.mode}/*.*`
        ];
        if (build.mode === "production") { plinkCmds.pop(); }

        const plinkArgs = [
            "-ssh",                   // force use of ssh protocol
            "-batch",                 // disable all interactive prompts
            "-pw", ep.credential.key, // auth key
            `${ep.credential.user}@${ep.host}`,
            plinkCmds.join(";")
        ];

        const pscpArgs = [
            "-pw", ep.credential.key, // auth key
            "-q",                     // quiet, don't show statistics
            "-r",                     // copy directories recursively
            toUploadPath,             // dir containing the files to upload, the "dir" itself (prod/dev/test) will be
            `${ep.credential.user}@${ep.host}:"${ep.path}/product/${name}"`
        ];

        // await copyFileAsync(
        //     joinPath(build.getBasePath(),
        //     "node_modules", "source-map", "lib", "mappings.wasm"),
        //     joinPath(toUploadPath, "mappings.wasm")
        // );

        logger.write(`   resource files prepared for upload to ${ep.host}`, 1, "");
        try
        {   logger.write("   plink: create / clear remmote directory", 1);
            let rc = await this.exec("plink " + plinkArgs.join(" "), "plink");
            if (rc.code === 0)
            {   logger.write("   pscp:  upload files", 1, "");
                rc = await this.exec("pscp " + pscpArgs.join(" "), "pscp");
                if (rc.code === 0)
                {   filesToUpload.forEach((f) => {
                        logger.write(`   ${logger.icons.tag.success} uploaded ${this._path_.basename(f)}`, 1)
                    });
                    logger.write("successfully uploaded resource files", 1);
                }
            }
        }
        catch (e)
        {   logger.error("error uploading resource files:");
            filesToUpload.forEach((f) => {
                logger.write(`   ${logger.icons.tag.error} upload ${this._path_.basename(f)} failed`, 1)
            });
            logger.error(e);
        }
        finally {
            try { await rm(toUploadPath, { recursive: true, force: true }); } catch (e) { logger.error(e); }
        }
    }
}


module.exports = WpwUploadPlugin.create;