UNPKG

@supernovaio/cli

Version:

Supernova.io Command Line Interface

526 lines (524 loc) 24.4 kB
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="55f07985-ed1d-587c-b9ec-26ea41bf7993")}catch(e){}}(); var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; import { Flags } from "@oclif/core"; import { action } from "@oclif/core/ux"; import { SentryTraced } from "@sentry/nestjs"; import { exec as execCallback } from "node:child_process"; import * as fs from "node:fs/promises"; import path, { resolve } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { z } from "zod"; import fsx from "fs-extra"; import crypto from "node:crypto"; import { commonFlags, SentryCommand } from "../types/index.js"; import { sleep } from "../utils/common.js"; import { tmpdir } from "node:os"; import { discoverAndUpdatePackageJson, discoverFilesForTemplates } from "../utils/discover.js"; import { spawnAndWait } from "../utils/spawn-and-wait.js"; import { buildTemplateUploadDockerfile, validateTemplateUploadNpmrcFile } from "../utils/template-upload-npmrc.js"; import { uploadFileToUrl } from "../utils/upload-file-to-signed-url.js"; import { fileExists, validateTemplates } from "../utils/validate-templates.js"; const exec = promisify(execCallback); const TemplateUploadConfig = z.object({}); export default class TemplateUpload extends SentryCommand { static args = {}; static description = "Upload component container template to Supernova"; static examples = ["<%= config.bin %> <%= command.id %> TemplateUpload "]; static hidden = true; static flags = { ...commonFlags, workspaceId: Flags.string({ char: "w", description: "Workspace ID to upload the template to", required: false }), designSystemId: Flags.string({ char: "d", description: "Design system ID to upload the template to", required: false, }), force: Flags.boolean({ char: "f", description: "Allows overwriting already published version of this template if it exists. This flag has no effect on new versions.", required: false, }), npmToken: Flags.string({ description: "Allows passing NPM token as a Docker secret. The token will be available as NPM_TOKEN env variable", required: false, }), discover: Flags.boolean({ description: "Run template and pattern discovery before upload to update package.json", required: false, }), dockerImageUri: Flags.string({ description: "Set this flag to upload image to a custom Docker registry, omit to use Supernova Docker registry. " + "The registry needs to be accessible by Supernova, " + "and authentication must be provided using 'dockerUser' and 'dockerPassword' flags", required: false, }), dockerUser: Flags.string({ description: "Username for accessing custom Docker registry, to be used in combination with 'dockerImageUri'. Supernova will pass it to 'docker login'.", required: false, }), dockerPassword: Flags.string({ description: "Password for accessing custom Docker registry, to be used in combination with 'dockerImageUri'. Supernova will pass it to 'docker login'. " + "Set this to '-' to accept password from stdin.", required: false, allowStdin: true, }), uploadAsTar: Flags.boolean({ description: "Export the built Docker image as a tar file and upload it through the files API instead of pushing it to a Docker registry.", required: false, default: false, }), imageTar: Flags.string({ description: "Path to a Docker image exported as tar. When used, CLI will skip internal Docker build and upload the image instead. " + "The image must comply with basic Supernova requirements.", required: false, }), debug: Flags.boolean({ description: "Preserve shell directory and App.tsx on template build failures for debugging", required: false, hidden: true, }), }; get commandId() { return TemplateUpload.id; } get configSchema() { return TemplateUploadConfig; } async run() { const { flags } = await this.parse(); this.validateFlags(flags); const apiClient = await this.apiClient(); if (flags.discover) { action.start("🔍 Running template and pattern discovery"); try { const { templates, patterns } = await discoverAndUpdatePackageJson(process.cwd()); action.stop(`found ${Object.keys(templates).length} templates, ${Object.keys(patterns).length} patterns`); } catch (error) { action.stop("failed"); if (error instanceof Error) this.error(`Discovery failed: ${error.message}`); else throw error; } } const isDiscoverOnly = flags.discover && !flags.workspaceId && !flags.designSystemId; if (isDiscoverOnly) { this.log("✅ Discovery completed. Use --workspaceId and --designSystemId to upload templates."); return; } if (!flags.workspaceId) { this.error("Missing required flag workspaceId"); } if (!flags.designSystemId) { this.error("Missing required flag designSystemId"); } let pkg; try { pkg = await readPackageJson(); } catch (error) { if (error instanceof Error) this.error(`Failed to read or parse package.json: ${error.message}`); else throw error; } if (pkg.supernova?.privateDependencies) { this.log(`The following packages will be linked as private dependencies: ${pkg.supernova.privateDependencies}`); for (const dependency of pkg.supernova.privateDependencies) { if (dependency.endsWith("/*")) continue; if (!pkg.dependencies[dependency]) { this.error(`Private dependency ${dependency} is not listed in 'dependencies'`); } } if (!(await fileExists(path.join(process.cwd(), ".npmrc")))) { this.error(`CLI needs private NPM registry access to be able to bundle private dependencies.\n` + `Please provide .npmrc file in the root directory and include neccessary access tokens.`); } } else { this.warn(`package.json doesn't contain 'supernova.privateDependencies' declaration.`); this.warn(`Dependencies coming from private registries will fail`); } try { await validateTemplateUploadNpmrcFile(path.join(process.cwd(), ".npmrc"), flags.npmToken); } catch (error) { if (error instanceof Error) this.error(error.message); else throw error; } let templatesWithThumbnailUrls; if (pkg.supernova?.templates) { const templates = await discoverFilesForTemplates(pkg.supernova.templates, process.cwd()); await validateTemplates(templates, this, flags.debug); templatesWithThumbnailUrls = await this.uploadThumbnailsAndBuildTemplates(apiClient, flags.workspaceId, templates); } const hasDesignModePlugin = await detectDesignModePlugin(); const buildData = await apiClient.sandboxes.builds.start({ workspaceId: flags.workspaceId, designSystemId: flags.designSystemId, name: pkg.name, version: pkg.version, isExistingVersionUpdateAllowed: flags.force ?? false, templates: templatesWithThumbnailUrls, hasDesignModePlugin, dockerImageUri: flags.dockerImageUri, ...(flags.dockerUser && flags.dockerPassword && { customAuth: { username: flags.dockerUser, password: flags.dockerPassword }, }), }); await this.validateDockerDaemon(); const buildDir = await this.createBuildDir(); try { if (!flags.imageTar) { await this.prepareBuildFolder(buildDir); await this.buildDockerImage(buildDir, buildData.build.dockerImageUri, flags.npmToken); } const { imageFileId } = await this.uploadDockerImage(apiClient, buildDir, buildData.build.dockerImageUri, flags); await this.remoteTemplateBuild(apiClient, buildData.build.id, imageFileId); this.log(`✅ Template has been successfully uploaded`); } finally { await this.deleteBuildDir(buildDir); } } validateFlags(flags) { if (flags.uploadAsTar) { if (flags.dockerImageUri) this.error(`Flag '--dockerImageUri' can't be used with '--uploadAsTar'.`); if (flags.dockerUser) this.error(`Flag '--dockerUser' can't be used with '--uploadAsTar'.`); if (flags.dockerPassword) this.error(`Flag '--dockerPassword' can't be used with '--uploadAsTar'.`); } if (flags.dockerImageUri) { if (!flags.dockerUser) this.error(`Flag '--dockerUser' is required when '--dockerImageUri' is set.`); if (!flags.dockerPassword) this.error(`Flag '--dockerPassword' is required when '--dockerImageUri' is set.`); } if (!flags.dockerImageUri) { if (flags.dockerUser) this.error(`Flag '--dockerUser' can't be used without '--dockerImageUri'.`); if (flags.dockerPassword) this.error(`Flag '--dockerPassword' can't be used without '--dockerImageUri'.`); } } async validateDockerDaemon() { await exec("docker info").catch(() => { this.error(`Docker is not available, please start docker daemon and try again`); }); } createBuildDir() { return fs.mkdtemp(path.join(tmpdir(), "supernova-template-bundle-")); } async deleteBuildDir(buildDir) { await fs.rm(buildDir, { recursive: true, force: true }); } async prepareBuildFolder(buildDir) { await fsx.copy(process.cwd(), buildDir, { filter(src) { return !src.includes("node_modules/") && !src.includes(".git/") && !src.includes(".out/"); }, }); const cliSrcPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); await fsx.copy(path.join(cliSrcPath, "docker-scripts"), path.join(buildDir, "docker-scripts")); } async buildDockerImage(buildDir, imageUri, npmToken) { this.log("🔨 Building docker image"); const dockerfile = buildTemplateUploadDockerfile(npmToken); const dockerBuildArgs = ["build", "-t", imageUri, "--pull", "--platform", "linux/amd64"]; if (npmToken) dockerBuildArgs.push("--secret", "id=NPM_TOKEN"); dockerBuildArgs.push("-f", "-", "."); await spawnAndWait("docker", dockerBuildArgs, { cwd: buildDir, env: npmToken ? { ...process.env, NPM_TOKEN: npmToken } : process.env, stdinData: dockerfile, }); this.log("🔨 Docker image has been built"); } async uploadDockerImage(apiClient, buildDir, imageUri, flags) { if (flags.uploadAsTar) { return { imageFileId: await this.uploadDockerImageAsTarFromDocker(apiClient, imageUri, flags.workspaceId), }; } if (flags.imageTar) { const fullImageTarPath = resolve(flags.imageTar); console.log(fullImageTarPath); return { imageFileId: await this.uploadDockerImageAsTarFromFileSystem(apiClient, fullImageTarPath, flags.workspaceId), }; } await this.pushDockerImage(buildDir, imageUri, flags); return {}; } async uploadDockerImageAsTarFromDocker(apiClient, imageUri, workspaceId) { const tarFilePath = path.join(tmpdir(), `${crypto.randomUUID()}.tar`); try { action.start("📦 Exporting docker image as tar"); await exec(`docker save -o "${tarFilePath}" ${imageUri}`); action.stop("done"); return await this.uploadDockerImageAsTarFromFileSystem(apiClient, tarFilePath, workspaceId); } finally { await fs.rm(tarFilePath, { force: true }); } } async uploadDockerImageAsTarFromFileSystem(apiClient, tarFilePath, workspaceId) { if (!(await fileExists(tarFilePath))) { this.error(`File ${tarFilePath} doesn't exist.`); } const size = await getFileSize(tarFilePath); const checksum = await calculateFileChecksum(tarFilePath); this.log(`📦 Docker tar file size: ${(size / 1000 / 1000).toFixed(2)}MB`); action.start(`⬆️ Uploading docker image tar`); const uploadResponse = await apiClient.files.upload({ ownerType: "Workspace", workspaceId, files: [{ name: path.basename(tarFilePath), size, checksum }], }); await uploadFilesToUrls(uploadResponse.uploadUrls, uploadResponse.files, [ { name: path.basename(tarFilePath), size, originalPath: tarFilePath }, ]); await apiClient.files.finalizeUpload({ ownerType: "Workspace", workspaceId, fileIds: uploadResponse.uploadUrls.map(file => file.fileId), }); action.stop("done"); return uploadResponse.files[0].id; } async pushDockerImage(buildDir, imageUri, flags) { const dockerHost = new URL(`https://${imageUri}`).hostname; let user; let password; let dockerLabel; if (flags.dockerImageUri) { user = flags.dockerUser; password = flags.dockerPassword; dockerLabel = dockerHost; } else { const { accessToken } = (await this.apiClient()).config; user = "cli"; password = accessToken; dockerLabel = "Supernova"; } if (!user || !password) { this.error(`Docker registry credentials are missing`); } await spawnAndWait("docker", ["login", dockerHost, "-u", user, "--password-stdin"], { stdinData: password, }); this.log(`⬆️ Uploading docker image to ${dockerLabel}`); await spawnAndWait("docker", ["push", imageUri], { cwd: buildDir }); this.log("⬆️ Docker image has been uploaded"); } async remoteTemplateBuild(client, buildId, imageFileId) { action.start("📦 Creating template with the image"); await client.sandboxes.builds.finalize(buildId, { imageFileId }); const pollIntervalMs = 2000; const timeoutMs = 15 * 60 * 1000; const startTime = Date.now(); let build; do { await sleep(pollIntervalMs); build = (await client.sandboxes.builds.get(buildId)).build; } while (build.state === "Building" && Date.now() - startTime < timeoutMs); if (build.state === "Success") { action.stop("done"); } else { action.stop("failed"); this.error(`Template creation failed`); } } async uploadThumbnailsAndBuildTemplates(apiClient, workspaceId, templates) { const templateNames = Object.values(templates).map(t => t.name); const duplicateNames = templateNames.filter((name, index) => templateNames.indexOf(name) !== index); if (duplicateNames.length > 0) { throw new Error(`Duplicate template names found: ${[...new Set(duplicateNames)].join(", ")}. Each template must have a unique name.`); } const templateCount = Object.keys(templates).length; action.start(`📸 Processing ${templateCount} template(s)`); const thumbnailFiles = []; for (const [templateId, template] of Object.entries(templates)) { if (!template.thumbnail) continue; const validation = await validateThumbnailFile(templateId, template.thumbnail, this); if (!validation) continue; try { const checksum = await calculateFileChecksum(validation.fullPath); thumbnailFiles.push({ name: validation.name, size: validation.size, checksum, originalPath: validation.fullPath, templateId, }); } catch (error) { this.warn(`Failed to calculate checksum for ${validation.fullPath}: ${error instanceof Error ? error.message : String(error)}`); continue; } } let thumbnailUrlMap = {}; if (thumbnailFiles.length > 0) { try { const uploadResponse = await apiClient.files.upload({ ownerType: "Workspace", workspaceId, files: thumbnailFiles.map(({ name, size, checksum }) => ({ name, size, checksum })), }); await uploadFilesToUrls(uploadResponse.uploadUrls, uploadResponse.files, thumbnailFiles); await apiClient.files.finalizeUpload({ ownerType: "Workspace", workspaceId, fileIds: uploadResponse.uploadUrls.map(f => f.fileId), }); thumbnailUrlMap = buildTemplateUrlMap(thumbnailFiles, uploadResponse.files); } catch (error) { this.warn(`Failed to upload thumbnails: ${error instanceof Error ? error.message : String(error)}`); this.warn("Continuing without uploaded thumbnails..."); } } const message = thumbnailFiles.length > 0 ? `uploaded ${thumbnailFiles.length} thumbnail(s)` : "no thumbnails to upload"; action.stop(message); const templatesWithUrls = Object.entries(templates).map(([id, template]) => ({ id, name: template.name, description: template.description, thumbnailUrl: thumbnailUrlMap[id], files: template.files, })); return templatesWithUrls; } } __decorate([ SentryTraced(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], TemplateUpload.prototype, "run", null); async function readPackageJson() { const pkgPath = path.join(process.cwd(), "package.json"); if (!(await fileExists(pkgPath))) throw new Error(`package.json file was not found in the current directory`); const raw = await fs.readFile(pkgPath, "utf8"); const pkg = JSON.parse(raw); if (typeof pkg !== "object" || pkg === null) throw new Error(`Error parsing package.json: not a json`); if (typeof pkg.name !== "string") throw new Error(`Error parsing package.json: 'name' must be defined`); if (typeof pkg.version !== "string") throw new Error(`Error parsing package.json: 'version' must be defined`); if (typeof pkg.dependencies !== "object" || pkg.dependencies === null) throw new Error(`Error parsing package.json: 'dependencies' must be defined`); if (pkg.supernova?.privateDependencies) { const privateDependencies = pkg.supernova?.privateDependencies; if (!Array.isArray(privateDependencies)) throw new TypeError(`supernova.privateDependencies must be an array`); for (const [i, d] of privateDependencies.entries()) { if (typeof d !== "string") { throw new TypeError(`supernova.privateDependencies[${i}] must be a string`); } if (!isValidPrivateDependencyEntry(d)) { throw new TypeError(`supernova.privateDependencies[${i}] must be a package name or scope wildcard like "@example/*"`); } } } return pkg; } function isValidPrivateDependencyEntry(entry) { if (!entry || entry.includes(" ")) return false; if (entry.endsWith("/*")) { const scope = entry.slice(0, -2); return scope.startsWith("@") && !scope.includes("/"); } return true; } async function validateThumbnailFile(templateId, thumbnailPath, logger) { const allowedExtensions = [".png", ".jpg", ".jpeg", ".svg", ".webp"]; const maxFileSize = 10 * 1024 * 1024; const fullPath = path.resolve(thumbnailPath); const name = path.basename(thumbnailPath); if (!(await fileExists(fullPath))) { logger.warn(`Thumbnail file not found for template ${templateId}: ${thumbnailPath}`); return undefined; } const extension = path.extname(thumbnailPath).toLowerCase(); if (!allowedExtensions.includes(extension)) { logger.warn(`Thumbnail file ${name} for template ${templateId} has unsupported format. Allowed formats: ${allowedExtensions.join(", ")}`); return undefined; } const size = await getFileSize(fullPath); if (size > maxFileSize) { logger.warn(`Thumbnail file ${name} for template ${templateId} is too large (${(size / 1024 / 1024).toFixed(2)}MB). Maximum size is 10MB.`); return undefined; } return { name, size, fullPath }; } async function uploadFilesToUrls(uploadUrls, fileResponses, localFiles) { const uploadTasks = uploadUrls.map(async (uploadUrl) => { const fileResponse = fileResponses.find(f => f.id === uploadUrl.fileId); const localFile = localFiles.find(f => f.name === fileResponse?.name && f.size === fileResponse?.size); if (localFile) { await uploadFileToUrl({ uploadUrl: uploadUrl.uploadUrl, filePath: localFile.originalPath, }); } }); await Promise.all(uploadTasks); } function buildTemplateUrlMap(thumbnailFiles, fileResponses) { const thumbnailUrlMap = {}; for (const thumbnailFile of thumbnailFiles) { const fileResponse = fileResponses.find(f => f.deduplicationKey === thumbnailFile.checksum); if (fileResponse) { thumbnailUrlMap[thumbnailFile.templateId] = fileResponse.url; } } return thumbnailUrlMap; } async function calculateFileChecksum(filePath) { const fileBuffer = await fs.readFile(filePath); const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer); return Buffer.from(hashBuffer).toString("hex"); } async function getFileSize(filePath) { const stats = await fs.stat(filePath); return stats.size; } async function detectDesignModePlugin() { const viteConfigPath = path.join(process.cwd(), "vite.config.ts"); try { const content = await fs.readFile(viteConfigPath, "utf8"); if (content.includes("supernovaDesignPlugin")) { return true; } } catch { } return undefined; } //# sourceMappingURL=template-upload.js.map //# debugId=55f07985-ed1d-587c-b9ec-26ea41bf7993