diff --git a/sdk/src/bin/index.ts b/sdk/src/bin/index.ts new file mode 100644 index 000000000..1ea436b75 --- /dev/null +++ b/sdk/src/bin/index.ts @@ -0,0 +1,184 @@ +import https from 'https'; +import axios from 'axios'; +import process from 'process'; +import path from 'path'; +import fs from 'fs'; + +function getUserHome(): string { + const ret = process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; + if (!ret) + throw new Error('Neither USERPROFILE or HOME are defined.'); + return ret; +} + +const scryptedHome = path.join(getUserHome(), '.scrypted'); +const loginPath = path.join(scryptedHome, 'login.json'); + +interface Login { + username?: string; + token?: string; +} + +interface LoginFile { + [ip: string]: Login; +} + +function getLogin(ip: string): { username: string; password: string } { + let login: LoginFile; + try { + login = JSON.parse(fs.readFileSync(loginPath).toString()); + } + catch { + login = {}; + } + + const ipLogin = login[ip]; + + return { + username: ipLogin?.username || '', + password: ipLogin?.token || '', + }; +} + +function showLoginError(): void { + console.error('Authorization required. Please log in with the following:'); + console.error(' npx scrypted login [ip]'); +} + +function toIpAndPort(ip: string): string { + if (ip.indexOf(':') === -1) + ip += ':10443'; + console.log(ip); + return ip; +} + +export function deploy(debugHost: string, noRebind?: boolean): Promise { + debugHost = toIpAndPort(debugHost); + + return new Promise((resolve, reject) => { + let out: string; + if (process.env.NODE_ENV === 'production') + out = path.resolve(process.cwd(), 'dist'); + else + out = path.resolve(process.cwd(), 'out'); + + const outFilename = 'plugin.zip'; + const main = path.resolve(out, outFilename); + if (!fs.existsSync(main)) { + console.error('npm run scrypted-webpack to build a webpack bundle for Scrypted.'); + reject(new Error(`Missing webpack bundle: ${main}`)); + return; + } + + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()); + const npmPackage = packageJson.name || ''; + + const rebindQuery = noRebind ? 'no-rebind' : ''; + + const deployUrl = `https://${debugHost}/web/component/script/deploy?${rebindQuery}&npmPackage=${npmPackage}`; + const setupUrl = `https://${debugHost}/web/component/script/setup?${rebindQuery}&npmPackage=${npmPackage}`; + + const fileContents = fs.readFileSync(main); + console.log(`deploying to ${debugHost}`); + + let auth: { username: string; password: string }; + try { + auth = getLogin(debugHost); + } + catch (e) { + console.error(e); + showLoginError(); + process.exit(1); + } + + const httpsAgent = new https.Agent({ + rejectUnauthorized: false + }); + + axios.post(setupUrl, packageJson, + { + auth, + timeout: 10000, + maxRedirects: 0, + httpsAgent, + validateStatus: function (status: number) { + if (status === 401) { + showLoginError(); + } + return status >= 200 && status < 300; + }, + }) + .then(() => { + console.log(`configured ${debugHost}`); + + return axios.post(deployUrl, fileContents, + { + auth: getLogin(debugHost), + timeout: 10000, + maxRedirects: 0, + httpsAgent, + validateStatus: function (status: number) { + return status >= 200 && status < 300; + }, + headers: { + "Content-Type": "application/zip " + } + } + ); + }) + .then(() => { + console.log(`deployed to ${debugHost}`); + resolve(); + }) + .catch((err: Error) => { + console.error(err.message); + if (axios.isAxiosError(err) && err.response?.data) { + console.log('\x1b[31m%s\x1b[0m', err.response.data); + } + reject(err); + }); + }); +} + +export function debug(debugHost: string, entryPoint?: string): Promise { + debugHost = toIpAndPort(debugHost); + + return new Promise((resolve, reject) => { + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()); + const npmPackage = packageJson.name || ''; + + const debugUrl = `https://${debugHost}/web/component/script/debug?npmPackage=${npmPackage}`; + console.log(`initiating debugger on ${debugHost}`); + + const httpsAgent = new https.Agent({ + rejectUnauthorized: false + }); + + axios.post(debugUrl, undefined, { + auth: getLogin(debugHost), + timeout: 10000, + maxRedirects: 0, + httpsAgent, + validateStatus: function (status: number) { + return status >= 200 && status < 300; + }, + }) + .then(() => { + console.log(`debugger ready on ${debugHost}`); + resolve(); + }) + .catch((err: Error) => { + console.error(err.message); + if (axios.isAxiosError(err) && err.response?.data) { + console.log('\x1b[31m%s\x1b[0m', err.response.data); + } + reject(err); + }); + }); +} + +export function getDefaultWebpackConfig(name: string): unknown { + return require(path.resolve(__dirname, `../../${name}`)); +} diff --git a/sdk/src/bin/scrypted-changelog.ts b/sdk/src/bin/scrypted-changelog.ts new file mode 100644 index 000000000..e3350d0b0 --- /dev/null +++ b/sdk/src/bin/scrypted-changelog.ts @@ -0,0 +1,61 @@ +#! /usr/bin/env node +import child_process from 'child_process'; +import util from 'util'; +import fs from 'fs'; + +const shas = child_process.execSync('git log --format=format:%H .'); +const exec = util.promisify(child_process.exec); + +const versions = new Map(); + +interface VersionInfo { + packageJson?: { version?: string }; + commit?: string; +} + +const promises = shas.toString().split('\n').map(sha => sha.trim()).map(async sha => { + try { + const result = await exec(`git show ${sha}:./package.json`); + const packageJson = JSON.parse(result.stdout); + const commit = await exec(`git rev-list --format=%B --max-count=1 ${sha}`); + return { + packageJson, + commit: commit.stdout, + }; + } + catch (e) { + console.error(e); + } +}); + +Promise.all(promises).then(pairs => { + const validPairs = (pairs as (VersionInfo | undefined)[]).filter((pair): pair is VersionInfo => !!pair?.packageJson?.version); + for (const valid of validPairs) { + const { packageJson, commit } = valid; + const version = packageJson!.version!; + if (!version) continue; + + let log = versions.get(version) || ''; + const firstLine = commit?.split('\n')[1] || ''; + + if ([version, 'wip', 'logging', 'wiop', 'publish'].includes(firstLine)) + continue; + + if (!log) { + log = ''; + versions.set(version, log); + } + + log += `${firstLine}\n`; + versions.set(version, log); + } + + let changeLog = '
\nChangelog\n\n'; + for (const [version, log] of versions.entries()) { + changeLog += `### ${version}\n\n${log}\n\n`; + } + + changeLog += '
\n'; + + fs.writeFileSync('CHANGELOG.md', changeLog); +}); diff --git a/sdk/src/bin/scrypted-debug.ts b/sdk/src/bin/scrypted-debug.ts new file mode 100644 index 000000000..9f7db167b --- /dev/null +++ b/sdk/src/bin/scrypted-debug.ts @@ -0,0 +1,19 @@ +#! /usr/bin/env node +import * as scrypted from './index.js'; + +function report(err: string): void { + process.nextTick(() => { + throw new Error(err); + }); +} + +if (process.argv.length != 3) { + report('Usage: npm run scrypted-debug [main.js]'); + process.exit(1); +} + +scrypted.debug(process.argv[2], process.argv[3]) + .catch((err: Error) => { + console.error(err.message); + report('debug failed'); + }); diff --git a/sdk/src/bin/scrypted-deploy-debug.ts b/sdk/src/bin/scrypted-deploy-debug.ts new file mode 100644 index 000000000..9c021ff7a --- /dev/null +++ b/sdk/src/bin/scrypted-deploy-debug.ts @@ -0,0 +1,22 @@ +#! /usr/bin/env node +import * as scrypted from './index.js'; + +function report(err: string): void { + process.nextTick(() => { + throw new Error(err); + }); +} + +if (process.argv.length < 3) { + report('Usage: npm run scrypted-deploy-debug [main.js]'); + process.exit(1); +} + +scrypted.deploy(process.argv[2], true) + .then(() => { + return scrypted.debug(process.argv[2], process.argv[3]); + }) + .catch((err: Error) => { + console.error(err.message); + report('deploy + debug failed'); + }); diff --git a/sdk/src/bin/scrypted-deploy.ts b/sdk/src/bin/scrypted-deploy.ts new file mode 100644 index 000000000..40dccb7c8 --- /dev/null +++ b/sdk/src/bin/scrypted-deploy.ts @@ -0,0 +1,19 @@ +#! /usr/bin/env node +import * as scrypted from './index.js'; + +function report(err: string): void { + process.nextTick(() => { + throw new Error(err); + }); +} + +if (process.argv.length != 3) { + report('Usage: npm run scrypted-deploy '); + process.exit(1); +} + +scrypted.deploy(process.argv[2]) + .catch((err: Error) => { + console.error(err.message); + report('deploy failed'); + }); diff --git a/sdk/src/bin/scrypted-package-json.ts b/sdk/src/bin/scrypted-package-json.ts new file mode 100644 index 000000000..3c6c79f2c --- /dev/null +++ b/sdk/src/bin/scrypted-package-json.ts @@ -0,0 +1,24 @@ +#! /usr/bin/env node +import fs from 'fs'; + +interface PackageJson { + scripts?: Record; + [key: string]: unknown; +} + +const pkg: PackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); +pkg.scripts = Object.assign({ + "scrypted-setup-project": "scrypted-setup-project", + "prescrypted-setup-project": "scrypted-package-json", + "build": "scrypted-webpack", + "preprepublishOnly": "scrypted-changelog", + "prepublishOnly": "NODE_ENV=production scrypted-webpack", + "prescrypted-vscode-launch": "scrypted-webpack", + "scrypted-vscode-launch": "scrypted-deploy-debug", + "scrypted-deploy-debug": "scrypted-deploy-debug", + "scrypted-debug": "scrypted-debug", + "scrypted-deploy": "scrypted-deploy", + "scrypted-changelog": "scrypted-changelog", + "scrypted-package-json": "scrypted-package-json", +}, pkg.scripts); +fs.writeFileSync('package.json', JSON.stringify(pkg, null, 3) + '\n'); diff --git a/sdk/src/bin/scrypted-setup-project.ts b/sdk/src/bin/scrypted-setup-project.ts new file mode 100644 index 000000000..a4ead2a4a --- /dev/null +++ b/sdk/src/bin/scrypted-setup-project.ts @@ -0,0 +1,7 @@ +#! /usr/bin/env node +import ncp from 'ncp'; +import path from 'path'; + +ncp(path.join(__dirname, '../../tsconfig.plugin.json'), 'tsconfig.json', (err) => { + if (err) console.error(err); +}); diff --git a/sdk/src/bin/scrypted-webpack.ts b/sdk/src/bin/scrypted-webpack.ts new file mode 100644 index 000000000..f665dff55 --- /dev/null +++ b/sdk/src/bin/scrypted-webpack.ts @@ -0,0 +1,269 @@ +#! /usr/bin/env node +try { + require('adm-zip'); +} +catch { + throw new Error('Please "npm install" in the "sdk" directory.'); +} + +import path from 'path'; +import process from 'process'; +import fs from 'fs'; +import os from 'os'; +import AdmZip from 'adm-zip'; +import rimraf from 'rimraf'; +import webpack from 'webpack'; +import tmp from 'tmp'; +import child_process from 'child_process'; +import { once } from 'events'; + +const cwd = process.cwd(); + +let out: string; +if (process.env.NODE_ENV === 'production') + out = path.resolve(cwd, 'dist'); +else + out = path.resolve(cwd, 'out'); + +if (fs.existsSync(path.resolve(cwd, 'src/main.py'))) { + const resolved = path.resolve(cwd, 'src'); + + const zip = new AdmZip(); + const readme = path.join(cwd, 'README.md'); + if (fs.existsSync(readme)) { + zip.addLocalFile(readme); + } + + zip.addLocalFolder(resolved); + + const sdk = path.join(__dirname, '../../types/scrypted_python/scrypted_sdk'); + zip.addLocalFolder(sdk, 'scrypted_sdk', filename => !filename.endsWith('.pyc')); + + const zipfs = path.join(cwd, 'fs'); + if (fs.existsSync(zipfs)) + zip.addLocalFolder(zipfs, 'fs'); + zip.writeZip(path.join(out, 'plugin.zip')); + process.exit(0); +} + +interface PackageJson { + name?: string; + exports?: Record; + optionalDependencies?: Record; + scrypted?: { + babel?: boolean; + rollup?: boolean; + interfaceDescriptors?: unknown; + }; + type?: string; +} + +const packageJson: PackageJson = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); +const interfaceDescriptors = packageJson.scrypted?.interfaceDescriptors; + +const optionalDependencies = Object.keys(packageJson.optionalDependencies || {}); + +if (packageJson.scrypted?.babel) { + process.env.SCRYPTED_WEBPACK_BABEL = 'true'; +} + +const defaultMainNodeJs = 'main.nodejs.js'; +interface Entry { + filename: string; + output: string; +} +const entries: (Entry | undefined)[] = []; + +if (packageJson.exports) { + for (const [key, value] of Object.entries(packageJson.exports)) { + entries.push({ + filename: key, + output: value, + }); + } +} +else { + for (const search of ['src/main.js', 'src/main.ts']) { + const resolved = path.resolve(cwd, search); + if (fs.existsSync(resolved)) { + entries.push({ + filename: search, + output: defaultMainNodeJs, + }); + break; + } + } +} + +const nodeWebpackConfig = 'webpack.nodejs.config.js'; + +if (!entries?.length) { + console.warn('unable to locate src/main.js or src/main.ts'); + console.warn('if a custom webpack config is used, will fall back to an entry configured there'); + entries.push(undefined); +} + +const zip = new AdmZip(); + +const readme = path.join(cwd, 'README.md'); +if (fs.existsSync(readme)) { + let readmeText = fs.readFileSync(readme).toString(); + const changelog = path.join(cwd, 'CHANGELOG.md'); + if (fs.existsSync(changelog)) { + readmeText += '\n\n\n

' + fs.readFileSync(changelog).toString(); + } + zip.addFile('README.md', Buffer.from(readmeText)); +} + +const NODE_PATH = path.resolve(__dirname, '..', '..', 'node_modules'); + +process.env.NODE_PATH = NODE_PATH; +require('module').Module._initPaths(); + +interface WebpackConfig { + entry?: Record | string; + output?: { + filename?: string; + path?: string; + }; + resolve?: { + alias?: Record; + }; +} + +async function rollup(): Promise { + if (out) + rimraf.sync(out); + + let rollupCmd = path.resolve(cwd, 'node_modules/.bin/rollup'); + + if (!fs.existsSync(rollupCmd)) { + rollupCmd = path.resolve(cwd, 'node_modules/@scrypted/sdk/node_modules/.bin/rollup'); + } + if (os.platform().startsWith('win')) { + rollupCmd += '.cmd'; + } + + const cp = child_process.spawn(rollupCmd, [ + '--config', path.resolve(__dirname, '../../rollup.nodejs.config.mjs'), + ], { + stdio: 'inherit', + }); + + await once(cp, 'exit'); + if (cp.exitCode) + throw new Error('rollup failed'); + + finishZip(); +} + +function finishZip(): void { + const jsFiles = fs.readdirSync(out, { + withFileTypes: true + }).filter(ft => ft.isFile() && ft.name.endsWith('.js')).map(ft => ft.name); + for (const js of jsFiles) { + zip.addLocalFile(path.join(out, js)); + const sourcemap = path.join(out, js + '.map'); + if (fs.existsSync(sourcemap)) + zip.addLocalFile(sourcemap); + console.log(js); + } + + const sdkPackageJson = require(path.join(__dirname, '../../package.json')); + zip.addFile('sdk.json', Buffer.from(JSON.stringify({ + version: (sdkPackageJson as { version: string }).version, + interfaceDescriptors, + }))); + + if (packageJson.type === 'module') { + zip.addFile('package.json', Buffer.from(JSON.stringify({ + type: 'module' + }))); + } + + const zipfs = path.join(cwd, 'fs'); + if (fs.existsSync(zipfs)) + zip.addLocalFolder(zipfs, 'fs'); + zip.writeZip(path.join(out, 'plugin.zip')); +} + +async function pack(): Promise { + if (out) + rimraf.sync(out); + + await new Promise((resolve, reject) => { + let webpackConfig: string; + const customWebpackConfig = path.resolve(cwd, nodeWebpackConfig); + const defaultWebpackConfig = path.resolve(__dirname, '..', '..', nodeWebpackConfig); + if (fs.existsSync(customWebpackConfig)) { + webpackConfig = customWebpackConfig; + } + else { + webpackConfig = defaultWebpackConfig; + } + + process.env.SCRYPTED_DEFAULT_WEBPACK_CONFIG = defaultWebpackConfig; + + const webpackEntries: Record = {}; + const config: WebpackConfig = require(webpackConfig); + for (const entry of entries) { + const normalizedEntry = entry || { + filename: (typeof config?.entry === 'object' ? config.entry?.main : config.entry) || '', + output: defaultMainNodeJs, + }; + + if (!normalizedEntry?.filename) { + console.error("no main.ts or main.js was found, and webpack config does not supply an entry file."); + console.error(normalizedEntry?.filename); + throw new Error(); + } + + const main = path.resolve(cwd, normalizedEntry.filename); + if (!fs.existsSync(main)) { + console.error("entry file specified in webpack config does not exist"); + throw new Error(); + } + + webpackEntries[normalizedEntry.output] = main; + } + + config.entry = webpackEntries; + config.output = config.output || {}; + config.output.filename = '[name]'; + config.output.path = out; + + config.resolve = config.resolve || {}; + config.resolve.alias = config.resolve.alias || {}; + + for (const opt of optionalDependencies) { + const t = tmp.tmpNameSync({ + postfix: '.js', + }); + fs.writeFileSync(t, ` + const e = __non_webpack_require__('${opt}'); + module.exports = e; + `); + config.resolve.alias![opt] = t; + } + + webpack(config as webpack.Configuration, (err, stats) => { + if (err) + return reject(err); + + if (stats?.hasErrors()) { + console.error(stats.toJson()?.errors); + return reject(new Error('webpack failed')); + } + + resolve(); + }); + }); + + finishZip(); +} + +(packageJson.scrypted?.rollup ? rollup : pack)() + .catch(e => process.nextTick(() => { + console.error(e); + throw new Error(e); + }));