sdk: make bin files typescript

This commit is contained in:
Koushik Dutta
2026-03-31 08:17:42 -07:00
parent 21ce1af766
commit b634330ab0
8 changed files with 605 additions and 0 deletions

184
sdk/src/bin/index.ts Normal file
View File

@@ -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<void> {
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<void> {
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}`));
}

View File

@@ -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<string, string>();
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 = '<details>\n<summary>Changelog</summary>\n\n';
for (const [version, log] of versions.entries()) {
changeLog += `### ${version}\n\n${log}\n\n`;
}
changeLog += '</details>\n';
fs.writeFileSync('CHANGELOG.md', changeLog);
});

View File

@@ -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 <ip_address> [main.js]');
process.exit(1);
}
scrypted.debug(process.argv[2], process.argv[3])
.catch((err: Error) => {
console.error(err.message);
report('debug failed');
});

View File

@@ -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 <ip_address> [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');
});

View File

@@ -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 <ip_address>');
process.exit(1);
}
scrypted.deploy(process.argv[2])
.catch((err: Error) => {
console.error(err.message);
report('deploy failed');
});

View File

@@ -0,0 +1,24 @@
#! /usr/bin/env node
import fs from 'fs';
interface PackageJson {
scripts?: Record<string, string>;
[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');

View File

@@ -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);
});

View File

@@ -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<string, string>;
optionalDependencies?: Record<string, string>;
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<br/><br/>' + 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, string> | string;
output?: {
filename?: string;
path?: string;
};
resolve?: {
alias?: Record<string, string>;
};
}
async function rollup(): Promise<void> {
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<void> {
if (out)
rimraf.sync(out);
await new Promise<void>((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<string, string> = {};
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);
}));