blob: 18a7ee569bb4ddadddd5e8025440b0cdcc1e1ee3 [file] [log] [blame] [edit]
// Copyright 2022 The ChromiumOS Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Script to manage installation of the extension.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as semver from 'semver';
import * as commonUtil from '../common/common_util';
function assertOutsideChroot() {
if (commonUtil.isInsideChroot()) {
throw new Error('installation outside chroot is required');
}
}
const GSUTIL = '../../scripts/gsutil';
const GS_PREFIX = 'gs://chromeos-velocity/ide/cros-ide';
async function execute(
name: string,
args: string[],
showStdout?: boolean
): Promise<string> {
const res = await commonUtil.exec(name, args, {
logger: new (class {
append(s: string): void {
process.stderr.write(s);
}
})(),
logStdout: showStdout,
});
if (res instanceof Error) {
throw res;
}
return res.stdout;
}
/**
* Find specified archive, or the latest one if version is unspecified.
*
* @throws Error if specified version is not found.
*/
export async function findArchive(
version?: semver.SemVer,
gsutil = GSUTIL
): Promise<Archive> {
// The result of `gsutil ls` is lexicographically sorted.
const stdout = await execute(gsutil, ['ls', GS_PREFIX]);
const archives = stdout
.trim()
.split('\n')
.map(url => {
return Archive.parse(url);
});
archives.sort(Archive.compareFn);
if (!version) {
return archives.pop()!;
}
for (const archive of archives) {
if (archive.version.compare(version) === 0) {
return archive;
}
}
throw new Error(`Version ${version} not found`);
}
// Assert the working directory is clean and get git commit hash.
async function cleanCommitHash() {
if (await execute('git', ['status', '--short'])) {
throw new Error('dirty git status; run the command in clean environment');
}
// IDE_CROS_MAIN_FOR_TESTING substitutes cros/main for manual testing.
const crosMain = process.env.IDE_CROS_MAIN_FOR_TESTING || 'cros/main';
try {
// Assert HEAD is an ancestor of cros/main (i.e. the HEAD is an
// already-merged commit).
await execute('git', ['merge-base', '--is-ancestor', 'HEAD', crosMain]);
} catch (_e) {
throw new Error('HEAD should be an ancestor of cros/main');
}
// HEAD commit should update version in package.json .
const diff = await execute('git', [
'diff',
'-p',
'HEAD~',
'--',
'**package.json',
]);
if (!/^\+\s*"version"\s*:/m.test(diff)) {
throw new Error('HEAD commit should update version in package.json');
}
return await execute('git', ['rev-parse', 'HEAD']);
}
class Archive {
readonly version: semver.SemVer;
constructor(readonly name: string, readonly hash?: string) {
this.version = versionFromFilename(name);
}
url() {
let res = `${GS_PREFIX}/${this.name}`;
if (this.hash !== undefined) {
res = `${res}@${this.hash}`;
}
return res;
}
static parse(url: string) {
const base = path.basename(url);
const [name, hash] = base.split('@');
return new Archive(name, hash);
}
static compareFn(first: Archive, second: Archive): number {
return first.version.compare(second.version);
}
}
// Matches the version suffix of a file name.
const VERSION_SUFFIX_RE = /-(\d.*)\.[^.]+/;
// Get version from filename such as "cros-ide-0.0.1.vsix"
function versionFromFilename(name: string): semver.SemVer {
const match = VERSION_SUFFIX_RE.exec(name);
if (!match) {
throw new Error(`Version suffix not found: ${name}`);
}
return new semver.SemVer(match[1]);
}
async function bumpDevVersion(): Promise<void> {
await execute('npm', ['version', 'prerelease', '--preid=dev']);
}
async function build(tempDir: string, hash?: string): Promise<Archive> {
await execute('npx', ['vsce@1.103.1', 'package', '-o', `${tempDir}/`]);
const localName: string = (await fs.promises.readdir(tempDir))[0];
return new Archive(localName, hash);
}
export async function buildAndUpload() {
const latestInGs = await findArchive();
const hash = await cleanCommitHash();
await commonUtil.withTempDir(async td => {
const built = await build(td, hash);
if (latestInGs.version.compare(built.version) >= 0) {
throw new Error(
`${built.name} is older than the latest published version ` +
`${latestInGs.name}. Update the version and rerun the program.`
);
}
await execute(GSUTIL, ['cp', path.join(td, built.name), built.url()]);
});
}
export async function installDev(exe: string) {
await commonUtil.withTempDir(async td => {
await bumpDevVersion();
const built = await build(td);
const src = path.join(td, built.name);
await execute(exe, ['--force', '--install-extension', src], true);
});
}
/**
* Install CrOS IDE extension.
*
* @param exe Path to the VSCode executable
* @param forceVersion Optional parameter specifying the version to install
*
* @throws Error if install fails
*/
export async function install(
exe: string,
forceVersion?: semver.SemVer,
gsutil: string = GSUTIL
) {
const src = await findArchive(forceVersion, gsutil);
assertOutsideChroot();
await commonUtil.withTempDir(async td => {
const dst = path.join(td, src.name);
await execute(gsutil, ['cp', src.url(), dst]);
const args = ['--install-extension', dst];
if (forceVersion) {
args.push('--force');
}
await execute(exe, args, true);
});
}
interface Config {
forceVersion?: semver.SemVer;
dev?: boolean;
upload?: boolean;
exe: string;
help?: boolean;
}
/**
* Parse args.
*
* @throws Error on invalid input
*/
export function parseArgs(args: string[]): Config {
args = args.slice(); // not to modify the given parameter
while (args.length > 0 && !args[0].startsWith('--')) {
args.shift();
}
const config: Config = {
exe: 'code',
};
while (args.length > 0) {
const flag = args.shift();
switch (flag) {
case '--dev':
config.dev = true;
break;
case '--upload':
config.upload = true;
break;
case '--force': {
const s = args.shift();
if (!s) {
throw new Error('Version is not given; see --help');
}
config.forceVersion = new semver.SemVer(s);
break;
}
case '--exe': {
const exe = args.shift();
if (!exe) {
throw new Error('Executable path is not given; see --help');
}
config.exe = exe;
break;
}
case '--help':
config.help = true;
break;
default:
throw new Error(`Unknown flag ${flag}; see --help`);
}
}
if (
(config.dev && config.upload) ||
(config.dev && config.forceVersion) ||
(config.upload && config.forceVersion)
) {
throw new Error('Invalid flag combination; see --help');
}
return config;
}
const USAGE = `
Usage:
install.sh [options]
Basic options:
--exe path|name
Specify the VS Code executable. By default 'code' is used. You need to set this flag
if you are using code-server or code-insiders
--force version
Force install specified version (example: --force 0.0.1)
Without this option, the latest version will be installed.
--help
Print this message
Developer options:
--dev
Build the extension from the current source code and install it
--upload
Build and upload the extension
`;
async function main() {
const config = parseArgs(process.argv);
if (config.help) {
console.log(USAGE);
return;
}
if (config.upload) {
await buildAndUpload();
return;
}
if ((await commonUtil.exec('which', [config.exe])) instanceof Error) {
throw new Error('VSCode executable not found. Did you forget `--exe`?');
}
if (config.dev) {
await installDev(config.exe);
return;
}
try {
await install(config.exe, config.forceVersion);
} catch (e) {
const message = (e as Error).message;
throw new Error(
`${message}\n` +
'Read http://go/cros-ide-quickstart and run the script in proper environment'
);
}
}
if (require.main === module) {
main().catch(e => {
console.error(e);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
}