@supernovaio/cli
Version:
Supernova.io Command Line Interface
526 lines (524 loc) • 24.4 kB
JavaScript
!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