UNPKG

@supernovaio/cli

Version:

Supernova.io Command Line Interface

392 lines (390 loc) 17.2 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]="6bd9ba28-eb6c-5dec-a2da-1bee8e04e590")}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 slugify from "@sindresorhus/slugify"; import AdmZip from "adm-zip"; import cliProgress from "cli-progress"; import inquirer from "inquirer"; import * as fs from "node:fs"; import path from "node:path"; import terminalLink from "terminal-link"; import { z } from "zod"; import { commonFlags, SentryCommand, storybookUrlForEnvironment } from "../types/index.js"; import { sleep } from "../utils/common.js"; import { uploadFileToUrl } from "../utils/upload-file-to-signed-url.js"; const MAX_ZIP_SIZE_BYTES = 500 * 1024 * 1024; const bytesToKB = (bytes) => (bytes / 1024).toFixed(2); const bytesToMB = (bytes) => (bytes / (1024 * 1024)).toFixed(2); const ImportStorybookConfig = z.object({ brandId: z.string().optional(), designSystemId: z.string().optional(), from: z.string().optional(), name: z.string().optional(), sourceId: z.string().optional(), publicAccess: z.boolean().optional(), }); const storybookEndpoint = (environment, designSystemId, name, format = "html") => { const host = storybookUrlForEnvironment(environment); return `${host}/design-systems/${designSystemId}/alias/${name}/index.${format}`; }; export default class ImportStorybook extends SentryCommand { static args = {}; static description = "Import storybook static export to Supernova"; static examples = ["<%= config.bin %> <%= command.id %> import-storybook "]; static flags = { ...commonFlags, brandId: Flags.string({ char: "b", description: "Import storybooks to brand of" }), designSystemId: Flags.string({ char: "d", description: "Import storybooks to design system of" }), from: Flags.string({ char: "f", description: "Directory with storybook static export to import.", }), name: Flags.string({ char: "n", description: "Import storybooks with name of" }), sourceId: Flags.string({ char: "s", description: "Import storybooks to source of" }), publicAccess: Flags.boolean({ required: false, description: "Allow public access to the published Storybook website. " + "Can be used in combination with public documentation.", }), }; get commandId() { return ImportStorybook.id; } get configSchema() { return ImportStorybookConfig; } async run() { const { flags } = await this.parse(); const config = this.configService.get(); const storybookConfig = config?.storybook ?? {}; const { sourceId } = { ...storybookConfig, ...flags }; const storybookDirectory = this.getStorybookDirectory(flags, storybookConfig); const storybookName = await this.getStorybookName(flags, storybookConfig); const designSystemId = await this.getDesignSystemId(flags, storybookConfig); const brandPersistentId = await this.getBrandId(flags, storybookConfig, designSystemId); const isPublic = this.getIsPublic(flags, storybookConfig); const apiClient = await this.apiClient(); const { designSystems, storybookSites } = apiClient; await this.validateDatasource(designSystems.sources, sourceId, designSystemId); this.log(`Preparing Storybook files from ${storybookDirectory}...`); const { sizeBytes, zipPath } = await this.createZipFromDirectory(storybookDirectory); const sizeValidation = this.validateZipSize(zipPath, sizeBytes); if (!sizeValidation.isValid) { this.error(sizeValidation.error); } const { storybookSiteVersion, uploadUrl } = await storybookSites.uploadInit({ designSystemId, isPublic, name: storybookName, }); await this.uploadArchiveToSignedUrl({ uploadUrl, zipPath }); this.log("✅ Upload complete."); const accessType = isPublic ? "Public" : "Private"; await runAsAction({ start: "Deploying Storybook to private Supernova hosting service", done: "\n✅ " + accessType + " Storybook deployed successfully!", fn: () => this.publishRemoteSite({ apiClient, storybookSiteVersionId: storybookSiteVersion.id }), }); const { sourceId: finalSourceId } = await runAsAction({ start: "Updating Storybook stories", done: "\n✅ Storybook stories have been updated!", fn: () => this.importStorybookStories({ apiClient, designSystemId, brandPersistentId, sourceId, storybookDirectory, storybookName, }), }); this.configService.update({ storybook: { designSystemId, from: storybookDirectory, name: storybookName, brandId: brandPersistentId, sourceId: finalSourceId, publicAccess: isPublic, }, }); } async getStorybookName(flags, config) { let result = flags.name ?? config.name; if (!result) { const choice = await inquirer.prompt([ { message: "Enter name of your storybook instance (it will be part of the URL)", name: "name", type: "input", }, ]); if (typeof choice.name === "string") { result = choice.name; } } if (!result) { this.error("Parameter `name` is required"); } return slugify(result, { lowercase: true }); } async getDesignSystemId(flags, config) { let designSystemId = flags.designSystemId ?? config.designSystemId; if (!designSystemId) { designSystemId = await this.promptDesignSystemId(); } if (!designSystemId) this.error("Parameter `designSystemId` is required"); return designSystemId; } async getBrandId(flags, config, designSystemId) { let brandId = flags.brandId ?? config.brandId; if (!brandId) { brandId = await this.promptBrandId(designSystemId); } if (!brandId) this.error("Parameter `brandId` is required"); return brandId; } getStorybookDirectory(flags, config) { const from = flags.from ?? config.from; if (!from) this.error("Parameter `from` is required"); const directoryValidation = this.validateStorybookDirectory(from); if (!directoryValidation.isValid) { this.error(directoryValidation.error); } return from; } getIsPublic(flags, config) { return flags.publicAccess ?? config.publicAccess ?? false; } async createZipFromDirectory(directoryPath) { const zip = new AdmZip(); let totalFiles = 0; let processedFiles = 0; const countFiles = (currentPath) => { const files = fs.readdirSync(currentPath); for (const file of files) { const filePath = path.join(currentPath, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { countFiles(filePath); } else { totalFiles++; } } }; countFiles(directoryPath); const progressBar = new cliProgress.SingleBar({ barCompleteChar: "\u2588", barIncompleteChar: "\u2591", format: "Creating zip [{bar}] {percentage}% | {value}/{total} files | Current: {filename}", hideCursor: true, }, cliProgress.Presets.shades_classic); progressBar.start(totalFiles, 0, { filename: "Initializing...", }); const addFilesToZip = (currentPath, relativePath = "") => { const files = fs.readdirSync(currentPath); for (const file of files) { const filePath = path.join(currentPath, file); const stat = fs.statSync(filePath); const displayPath = path.join(relativePath, file); if (stat.isDirectory()) { addFilesToZip(filePath, displayPath); } else { processedFiles++; progressBar.update(processedFiles, { filename: displayPath.length > 40 ? "..." + displayPath.slice(Math.max(0, displayPath.length - 37)) : displayPath, }); zip.addLocalFile(filePath, relativePath); } } }; try { addFilesToZip(directoryPath); } finally { progressBar.stop(); } const zipPath = `${directoryPath}.zip`; await zip.writeZipPromise(zipPath); const sizeBytes = fs.statSync(zipPath).size; return { sizeBytes, zipPath }; } getIndexJson(directoryPath) { const indexJsonPath = path.join(directoryPath, "index.json"); if (fs.existsSync(indexJsonPath)) { return JSON.parse(fs.readFileSync(indexJsonPath, "utf8")); } const storiesJsonPath = path.join(directoryPath, "stories.json"); if (fs.existsSync(storiesJsonPath)) { return JSON.parse(fs.readFileSync(storiesJsonPath, "utf8")); } throw new Error(`Can't open "index.json" or "stories.json" in path ${directoryPath}`); } async uploadArchiveToSignedUrl(input) { const { uploadUrl, zipPath } = input; this.log(`Securely uploading ${zipPath} to Supernova...`); const fileSize = fs.statSync(zipPath).size; const fileSizeKB = Number.parseFloat(bytesToKB(fileSize)); const progressBar = new cliProgress.SingleBar({ barCompleteChar: "\u2588", barIncompleteChar: "\u2591", format: "Uploading |{bar}| {percentage}% || {value}/{total} kB", hideCursor: true, }, cliProgress.Presets.shades_classic); progressBar.start(fileSizeKB, 0); try { await uploadFileToUrl({ uploadUrl, filePath: zipPath, contentType: "application/zip", onProgress({ uploadedBytes }) { progressBar.update(Number.parseFloat(bytesToKB(uploadedBytes))); }, }); progressBar.update(fileSizeKB); progressBar.stop(); } catch (error) { progressBar.stop(); throw error; } } async publishRemoteSite(input) { const { apiClient, storybookSiteVersionId } = input; const getStatus = async () => { const { storybookSiteVersion } = await apiClient.storybookSites.getVersion(storybookSiteVersionId); return storybookSiteVersion.uploadStatus; }; const { storybookSiteVersion: finalizedVersion } = await apiClient.storybookSites.uploadFinailize(storybookSiteVersionId); let lastStatus = finalizedVersion.uploadStatus; for (let i = 0; i < 60; i++) { lastStatus = await getStatus(); if (lastStatus !== "InProgress" && lastStatus !== "PendingUpload") break; await sleep(1000); } switch (lastStatus) { case "PendingUpload": return this.error("Storybook deployment initialization failed"); case "Timeout": return this.error("Storybook deployment initialization has timed out"); case "Failed": return this.error("Storybook deployment has failed"); case "InProgress": return this.error("Storybook deployment has timed out"); } } async importStorybookStories(input) { const { apiClient, brandPersistentId, designSystemId, storybookDirectory, storybookName } = input; let { sourceId } = input; const sourcesEndpoint = apiClient.designSystems.sources; const storybookUrl = storybookEndpoint(this.env, designSystemId, storybookName); let storiesCount = 0; if (sourceId) { const sourceUpdateResult = await sourcesEndpoint.updateStorybookImport(designSystemId, "head", { payload: this.getIndexJson(storybookDirectory), sourceId, }); storiesCount = sourceUpdateResult.storiesCount; } else { const { source } = await sourcesEndpoint.create(designSystemId, { brandPersistentId, description: "CLI", indexUrl: storybookEndpoint(this.env, designSystemId, storybookName, "json"), payload: this.getIndexJson(storybookDirectory), type: "Storybook", userUrl: storybookUrl, fileName: storybookName, }); storiesCount = source.storybook.storiesCount; sourceId = source.id; } this.log(`✅ Imported ${storiesCount} component stories into Supernova!`); const link = terminalLink("here", storybookUrl, { fallback: (_, url) => url }); this.log(`🔒 Access your Storybook ${link}`); return { sourceId }; } validateStorybookDirectory(directoryPath) { if (!fs.existsSync(directoryPath)) { return { error: `Directory not found: ${directoryPath}`, isValid: false }; } if (!fs.statSync(directoryPath).isDirectory()) { return { error: `Not a directory: ${directoryPath}`, isValid: false }; } const requiredFiles = ["index.html", "iframe.html"]; const missingFiles = requiredFiles.filter(file => !fs.existsSync(path.join(directoryPath, file))); if (missingFiles.length > 0) { return { error: `Directory does not appear to be a valid Storybook export. Missing files: ${missingFiles.join(", ")}`, isValid: false, }; } const requiredAtLeastOneFile = ["index.json", "stories.json"]; if (!requiredAtLeastOneFile.some(file => fs.existsSync(path.join(directoryPath, file)))) { return { error: `Storybook directory is missing "index.json" or "stories.json".\n` + `Tip: if running Storybook 6 or lower, you might need to enable "buildStoriesJson: true" in your Storybook config.`, isValid: false, }; } return { isValid: true }; } validateZipSize(zipPath, sizeBytes) { if (sizeBytes > MAX_ZIP_SIZE_BYTES) { try { fs.unlinkSync(zipPath); } catch { } return { error: `Zip file is too large: ${bytesToMB(sizeBytes)}MB. Maximum allowed size is ${bytesToMB(MAX_ZIP_SIZE_BYTES)}MB`, isValid: false, }; } return { isValid: true }; } async validateDatasource(sourcesEndpoint, sourceId, designSystemId) { if (sourceId) { const storybookDatasource = await sourcesEndpoint.get(designSystemId, sourceId).catch(() => null); if (!storybookDatasource) { this.error("This data source was deleted. Remove supernova.config.json and try again to create a new data source."); } } } } __decorate([ SentryTraced(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], ImportStorybook.prototype, "run", null); async function runAsAction(args) { action.start(args.start); try { const result = await args.fn(); action.stop(args.done); return result; } catch (error) { action.stop(); throw error; } } //# sourceMappingURL=storybook-import.js.map //# debugId=6bd9ba28-eb6c-5dec-a2da-1bee8e04e590