blob: 31aaa95975ee051e3c497b2b1e7fa02c884460b3 [file]
#!/usr/bin/env vpython3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Runs the scripts defined in package.json without depending on npm.
Usage:
dt.py List the available scripts.
dt.py <script> [args] Run the package.json script <script>.
Everything after <script> is forwarded to the script as-is, `--` included.
Unlike with `npm run`, options don't need `--` to get past dt.py, so
`npm run test -- foo` becomes `dt.py test foo`.
This reproduces the parts of `npm run` that the scripts in package.json rely
on:
* `pre<script>` and `post<script>` run before and after <script>.
* Scripts run from the directory containing package.json, through the
platform shell (`sh -c` or `cmd /c`), with node_modules/.bin on the PATH.
Like the scripts themselves, this needs `vpython3` (depot_tools) on the PATH.
"""
import argparse
import json
import os
import shlex
import signal
import subprocess
import sys
REPO_ROOT = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DESCRIPTION = """\
Runs a script from package.json without npm. Arguments after the script name
are forwarded to it as-is."""
def load_scripts(package_json_path):
"""Returns the "scripts" of the package.json file at the given path."""
with open(package_json_path, encoding='utf-8') as f:
return json.load(f).get('scripts', {})
def quote_arg(arg):
"""Quotes `arg` so that the platform shell passes it on unchanged.
On Windows this is best effort: the quoting follows the rules for
splitting a command line into arguments, but does not escape the special
characters of cmd.exe, such as & or |.
"""
if os.name == 'nt':
return subprocess.list2cmdline([arg])
return shlex.quote(arg)
def plan_commands(scripts, name, args):
"""Returns the shell commands that running script `name` consists of.
Like `npm run`, this runs `pre<name>` and `post<name>` around the script if
they exist, and appends `args` to the script.
"""
commands = []
if f'pre{name}' in scripts:
commands.append(scripts[f'pre{name}'])
quoted_args = [quote_arg(arg) for arg in args]
commands.append(' '.join([scripts[name]] + quoted_args))
if f'post{name}' in scripts:
commands.append(scripts[f'post{name}'])
return commands
def script_env(root):
"""Returns the environment scripts run in, set up like `npm run` does."""
env = dict(os.environ)
bin_dir = os.path.join(root, 'node_modules', '.bin')
env['PATH'] = os.pathsep.join(filter(None, [bin_dir, env.get('PATH')]))
return env
def run_command(command, cwd, env):
"""Runs `command` in the platform shell and returns its exit code.
Like `subprocess.call`, this returns -N for a command that was killed by
signal N (POSIX).
"""
try:
return subprocess.call(command, shell=True, cwd=cwd, env=env)
except KeyboardInterrupt:
# subprocess has given the child a moment to exit, and killed it if it
# didn't. Report the interruption the way a shell does.
return 128 + signal.SIGINT
def format_scripts(scripts):
"""Returns a listing of the scripts and the commands they run."""
width = max(map(len, scripts), default=0)
lines = ['scripts:']
for name, command in scripts.items():
lines.append(f' {name:<{width}} {command}')
return '\n'.join(lines)
def create_parser(scripts):
"""Returns the parser for the arguments that dt.py itself takes."""
parser = argparse.ArgumentParser(
prog='dt.py',
usage='%(prog)s [-h] [script] [args ...]',
description=DESCRIPTION,
epilog=format_scripts(scripts),
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('script',
nargs='?',
choices=list(scripts),
metavar='script',
help='the script to run; omit to list the scripts')
return parser
def split_argv(argv):
"""Splits `argv` into the arguments for dt.py and those for the script.
The script is named by the first argument that is not an option. The
arguments after it are for the script and have to reach it untouched, so
they are kept away from argparse, which would try to make sense of the
options among them.
"""
for index, arg in enumerate(argv):
if not arg.startswith('-'):
return argv[:index + 1], argv[index + 1:]
return argv, []
def main(argv=None, root=REPO_ROOT):
"""Runs the script named in the arguments and returns an exit code."""
argv = sys.argv[1:] if argv is None else argv
scripts = load_scripts(os.path.join(root, 'package.json'))
parser = create_parser(scripts)
own_args, script_args = split_argv(argv)
name = parser.parse_args(own_args).script
if name is None:
parser.print_help()
return 0
env = script_env(root)
for command in plan_commands(scripts, name, script_args):
print(f'$ {command}', file=sys.stderr)
returncode = run_command(command, root, env)
if returncode:
return returncode
return 0
if __name__ == '__main__':
sys.exit(main())