process.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. /**
  2. * Process helpers shared by the release scripts: the release steps drive `git`,
  3. * `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours.
  4. */
  5. import { spawnSync } from 'node:child_process'
  6. import { realpathSync } from 'node:fs'
  7. import { fileURLToPath } from 'node:url'
  8. /** Where and with what environment a release step runs a command. */
  9. export interface RunOptions {
  10. /** Working directory; defaults to the current one. */
  11. readonly cwd?: string
  12. /** Child environment; defaults to this process's. */
  13. readonly env?: NodeJS.ProcessEnv
  14. }
  15. /** What a command produced, for a caller that decides what a failure means. */
  16. export interface CommandResult {
  17. /** Exit status, or null when a signal ended the process. */
  18. readonly status: number | null
  19. /** Captured standard output. */
  20. readonly stdout: string
  21. /** Captured standard error. */
  22. readonly stderr: string
  23. }
  24. /**
  25. * Run a command and capture its output without judging the exit status.
  26. * @param command - executable name.
  27. * @param args - command arguments.
  28. * @param options - working directory and environment.
  29. * @returns The exit status and captured streams.
  30. */
  31. export function attempt(command: string, args: readonly string[], options: RunOptions = {}): CommandResult {
  32. const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, encoding: 'utf8' })
  33. if (result.error !== undefined) throw result.error
  34. return { status: result.status, stdout: result.stdout, stderr: result.stderr }
  35. }
  36. /**
  37. * Run a command, capture its output, and echo it once the command exits.
  38. *
  39. * A step that both shows what a command said and classifies its own failure
  40. * needs both halves: the output has to reach the workflow log, and the caller has
  41. * to read it to decide whether a failure is worth retrying.
  42. *
  43. * This is not live progress. `spawnSync` returns only after the child exits, so
  44. * nothing appears while the command runs, and the two streams are echoed one
  45. * after the other — all of stdout, then all of stderr — which loses their
  46. * interleaving. For an npm publish that matters in one visible way: `npm notice`
  47. * lines go to stderr while the `+ name@version` confirmation goes to stdout, so
  48. * the log shows the confirmation first. Live progress would need an
  49. * asynchronous spawn with data listeners.
  50. * @param command - executable name.
  51. * @param args - command arguments.
  52. * @param options - working directory and environment.
  53. * @returns The exit status and captured streams.
  54. */
  55. export function attemptEchoed(command: string, args: readonly string[], options: RunOptions = {}): CommandResult {
  56. const result = spawnSync(command, [...args], {
  57. cwd: options.cwd,
  58. env: options.env,
  59. encoding: 'utf8',
  60. // 'inherit' would leave nothing to capture, so the streams are piped and
  61. // echoed instead.
  62. stdio: ['inherit', 'pipe', 'pipe'],
  63. })
  64. if (result.error !== undefined) throw result.error
  65. if (result.stdout !== '') process.stdout.write(result.stdout)
  66. if (result.stderr !== '') process.stderr.write(result.stderr)
  67. return { status: result.status, stdout: result.stdout, stderr: result.stderr }
  68. }
  69. /**
  70. * Run a command, capture its standard output, and fail on a non-zero exit.
  71. * @param command - executable name.
  72. * @param args - command arguments.
  73. * @param options - working directory and environment.
  74. * @returns The trimmed standard output.
  75. */
  76. export function capture(command: string, args: readonly string[], options: RunOptions = {}): string {
  77. const result = attempt(command, args, options)
  78. if (result.status !== 0) {
  79. throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}:\n${result.stdout}\n${result.stderr}`)
  80. }
  81. return result.stdout.trim()
  82. }
  83. /**
  84. * Run a command with inherited streams, so its progress reaches the log, and
  85. * fail on a non-zero exit.
  86. * @param command - executable name.
  87. * @param args - command arguments.
  88. * @param options - working directory and environment.
  89. */
  90. export function run(command: string, args: readonly string[], options: RunOptions = {}): void {
  91. const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' })
  92. if (result.error !== undefined) throw result.error
  93. if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
  94. }
  95. /**
  96. * Whether this module is the process entry point.
  97. *
  98. * The release scripts are both commands and modules: a test imports their pure
  99. * logic, and importing a module runs its body, so an unguarded `main()` would
  100. * run the wrong command with the wrong arguments.
  101. * @param moduleUrl - the caller's `import.meta.url`.
  102. * @returns True when Node started this module.
  103. */
  104. export function isEntry(moduleUrl: string): boolean {
  105. const invoked = process.argv[1]
  106. if (invoked === undefined) return false
  107. return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl))
  108. }