@ebowwa/seedinstallation 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ebowwa Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @cheapspaces/seedInstallation
2
+
3
+ Composable server installation utilities for edge deployment automation. This package provides a set of utilities for provisioning and managing servers, both locally and via SSH.
4
+
5
+ ## Features
6
+
7
+ - **Sudo command execution** - Run commands with sudo privileges locally or via SSH
8
+ - **Package management** - Install system packages (apt, dnf, apk)
9
+ - **File operations** - Write to privileged paths with proper permissions
10
+ - **Systemd service management** - Create, enable, and manage systemd services
11
+ - **Runtime installation** - Install and configure runtimes like Bun
12
+ - **Device-code authentication** - Handle OAuth device flows for Doppler, GitHub, Tailscale
13
+ - **Bootstrap tracking** - Track provisioning phases and status
14
+ - **Git cloning** - Composable git clone with shallow/sparse support
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @cheapspaces/seedInstallation
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### Local Command Execution
25
+
26
+ ```typescript
27
+ import { sudo, pkgInstall } from '@cheapspaces/seedInstallation';
28
+
29
+ // Run a command with sudo
30
+ const result = await sudo(['apt-get', 'update'], {
31
+ context: { type: 'local' }
32
+ });
33
+
34
+ // Install packages
35
+ await pkgInstall(['git', 'curl', 'vim'], {
36
+ context: { type: 'local' },
37
+ pm: 'apt'
38
+ });
39
+ ```
40
+
41
+ ### Remote SSH Execution
42
+
43
+ ```typescript
44
+ import { sudo, writeFile } from '@cheapspaces/seedInstallation';
45
+
46
+ // Run commands over SSH
47
+ const result = await sudo(['systemctl', 'status', 'nginx'], {
48
+ context: {
49
+ type: 'ssh',
50
+ host: '192.168.1.100',
51
+ user: 'root',
52
+ keyPath: '/path/to/key'
53
+ }
54
+ });
55
+
56
+ // Write files to remote server
57
+ await writeFile('/etc/nginx/nginx.conf', configContent, {
58
+ context: { type: 'ssh', host: '192.168.1.100' },
59
+ mode: '644'
60
+ });
61
+ ```
62
+
63
+ ### Systemd Service Management
64
+
65
+ ```typescript
66
+ import {
67
+ createServiceUnit,
68
+ enableAndStartService,
69
+ getServiceStatus
70
+ } from '@cheapspaces/seedInstallation';
71
+
72
+ // Create a systemd service
73
+ await createServiceUnit('myapp', {
74
+ description: 'My Application',
75
+ workingDirectory: '/opt/myapp',
76
+ execStart: '/usr/bin/node /opt/myapp/index.js',
77
+ restart: 'always',
78
+ environment: {
79
+ NODE_ENV: 'production'
80
+ }
81
+ }, { context: { type: 'local' } });
82
+
83
+ // Enable and start the service
84
+ await enableAndStartService('myapp', {
85
+ context: { type: 'local' }
86
+ });
87
+
88
+ // Check service status
89
+ const status = await getServiceStatus('myapp', {
90
+ context: { type: 'local' }
91
+ });
92
+ ```
93
+
94
+ ### Device-Code Authentication
95
+
96
+ ```typescript
97
+ import { deviceAuth, dopplerConfig } from '@cheapspaces/seedInstallation';
98
+
99
+ // Authenticate with Doppler via device code
100
+ const result = await deviceAuth(dopplerConfig, {
101
+ context: { type: 'local' },
102
+ onPoll: (attempt, result) => {
103
+ console.log(`Polling... attempt ${attempt}`);
104
+ }
105
+ });
106
+
107
+ if (result.success) {
108
+ console.log(`Visit: ${result.url}`);
109
+ console.log(`Code: ${result.code}`);
110
+ }
111
+ ```
112
+
113
+ ### Git Cloning
114
+
115
+ ```typescript
116
+ import { clone } from '@cheapspaces/seedInstallation/clone';
117
+
118
+ // Basic clone
119
+ const { path, commit } = await clone({
120
+ repo: 'https://github.com/user/repo.git'
121
+ });
122
+
123
+ // Shallow clone of specific branch
124
+ await clone({
125
+ repo: 'https://github.com/user/repo.git',
126
+ ref: 'main',
127
+ depth: 1,
128
+ dest: 'my-repo'
129
+ });
130
+
131
+ // Sparse checkout (only specific paths)
132
+ await clone({
133
+ repo: 'https://github.com/user/repo.git',
134
+ sparse: ['src/lib', 'src/types'],
135
+ depth: 1
136
+ });
137
+ ```
138
+
139
+ ## API
140
+
141
+ ### Modules
142
+
143
+ - `.` - Main exports (all utilities)
144
+ - `./clone` - Git clone utilities
145
+ - `./sudo` - Sudo command execution
146
+ - `./runtime` - Runtime installation and PATH management
147
+ - `./systemd` - Systemd service management
148
+ - `./device-auth` - Device-code authentication flows
149
+ - `./bootstrap` - Bootstrap status tracking
150
+
151
+ ### Types
152
+
153
+ ```typescript
154
+ interface ExecContext {
155
+ type: 'local' | 'ssh';
156
+ host?: string;
157
+ user?: string;
158
+ keyPath?: string;
159
+ port?: number;
160
+ }
161
+
162
+ interface ExecResult {
163
+ stdout: string;
164
+ stderr: string;
165
+ exitCode: number;
166
+ ok: boolean;
167
+ }
168
+ ```
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Bootstrap/provisioning status tracking for edge servers.
3
+ * Manages phase markers, status files, and polling for completion.
4
+ * Works with both local and SSH contexts via ExecContext from sudo.ts.
5
+ */
6
+ import type { SudoOptions, ExecResult } from "./sudo.js";
7
+ export interface BootstrapPhase {
8
+ /** Phase name (e.g. "bun", "seed", "doppler") */
9
+ name: string;
10
+ /** Current status of this phase */
11
+ status: "pending" | "running" | "complete" | "failed";
12
+ /** ISO timestamp when phase started */
13
+ startedAt?: string;
14
+ /** ISO timestamp when phase completed */
15
+ completedAt?: string;
16
+ /** Error message if failed */
17
+ error?: string;
18
+ }
19
+ export interface BootstrapStatus {
20
+ /** Overall bootstrap status */
21
+ status: "started" | "running" | "complete" | "failed";
22
+ /** ISO timestamp when bootstrap started */
23
+ startedAt?: string;
24
+ /** ISO timestamp when bootstrap completed */
25
+ completedAt?: string;
26
+ /** Source of bootstrap (e.g. "cloud-init", "manual") */
27
+ source?: string;
28
+ /** Individual phase statuses */
29
+ phases: Record<string, BootstrapPhase>;
30
+ /** Raw file content */
31
+ raw: string;
32
+ }
33
+ export interface BootstrapPollOptions {
34
+ /** Execution context for running commands */
35
+ context: SudoOptions["context"];
36
+ /** Maximum poll attempts (default: 30) */
37
+ maxAttempts?: number;
38
+ /** Interval between polls in ms (default: 2000) */
39
+ intervalMs?: number;
40
+ /** Callback called on each poll attempt */
41
+ onProgress?: (attempt: number, status: BootstrapStatus) => void;
42
+ }
43
+ /**
44
+ * Read and parse a bootstrap status file.
45
+ *
46
+ * File format is key=value lines:
47
+ * ```
48
+ * status=started
49
+ * started_at=2024-01-01T00:00:00+00:00
50
+ * source=cloud-init
51
+ * phase.bun.status=complete
52
+ * phase.bun.completed_at=2024-01-01T00:01:00+00:00
53
+ * phase.seed.status=running
54
+ * phase.seed.started_at=2024-01-01T00:01:30+00:00
55
+ * ```
56
+ */
57
+ export declare function getBootstrapStatus(statusFile: string, opts: SudoOptions): Promise<BootstrapStatus>;
58
+ /**
59
+ * Parse bootstrap status from file content.
60
+ */
61
+ export declare function parseBootstrapStatus(content: string): BootstrapStatus;
62
+ /**
63
+ * Write initial bootstrap status file.
64
+ */
65
+ export declare function initBootstrap(statusFile: string, source: string, opts: SudoOptions): Promise<ExecResult>;
66
+ /**
67
+ * Mark a phase as started.
68
+ */
69
+ export declare function startPhase(statusFile: string, phase: string, opts: SudoOptions): Promise<ExecResult>;
70
+ /**
71
+ * Mark a phase as complete.
72
+ */
73
+ export declare function completePhase(statusFile: string, phase: string, opts: SudoOptions): Promise<ExecResult>;
74
+ /**
75
+ * Mark a phase as failed.
76
+ */
77
+ export declare function failPhase(statusFile: string, phase: string, error: string, opts: SudoOptions): Promise<ExecResult>;
78
+ /**
79
+ * Mark entire bootstrap as complete.
80
+ */
81
+ export declare function completeBootstrap(statusFile: string, opts: SudoOptions): Promise<ExecResult>;
82
+ /**
83
+ * Mark bootstrap as failed.
84
+ */
85
+ export declare function failBootstrap(statusFile: string, error: string, opts: SudoOptions): Promise<ExecResult>;
86
+ /**
87
+ * Check if a marker file exists.
88
+ * Use for simple completion flags (e.g. .seed-setup-complete).
89
+ */
90
+ export declare function checkMarker(markerPath: string, opts: SudoOptions): Promise<boolean>;
91
+ /**
92
+ * Create a marker file.
93
+ */
94
+ export declare function setMarker(markerPath: string, opts: SudoOptions): Promise<ExecResult>;
95
+ /**
96
+ * Remove a marker file.
97
+ */
98
+ export declare function removeMarker(markerPath: string, opts: SudoOptions): Promise<ExecResult>;
99
+ /**
100
+ * Poll until bootstrap completes or times out.
101
+ *
102
+ * Returns the final status (complete or failed/timeout).
103
+ */
104
+ export declare function waitForBootstrap(statusFile: string, opts: BootstrapPollOptions): Promise<{
105
+ completed: boolean;
106
+ status: BootstrapStatus;
107
+ }>;
108
+ /**
109
+ * Poll until a marker file exists.
110
+ */
111
+ export declare function waitForMarker(markerPath: string, opts: BootstrapPollOptions): Promise<{
112
+ exists: boolean;
113
+ timedOut: boolean;
114
+ }>;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Bootstrap/provisioning status tracking for edge servers.
3
+ * Manages phase markers, status files, and polling for completion.
4
+ * Works with both local and SSH contexts via ExecContext from sudo.ts.
5
+ */
6
+ // Re-export helpers
7
+ async function exec(args, opts) {
8
+ const { sudo } = await import("./sudo.js");
9
+ return sudo(args, opts);
10
+ }
11
+ async function writeFile(path, content, opts) {
12
+ const { writeFile: wf } = await import("./sudo.js");
13
+ return wf(path, content, opts);
14
+ }
15
+ // ---------------------------------------------------------------------------
16
+ // Status file parsing
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Read and parse a bootstrap status file.
20
+ *
21
+ * File format is key=value lines:
22
+ * ```
23
+ * status=started
24
+ * started_at=2024-01-01T00:00:00+00:00
25
+ * source=cloud-init
26
+ * phase.bun.status=complete
27
+ * phase.bun.completed_at=2024-01-01T00:01:00+00:00
28
+ * phase.seed.status=running
29
+ * phase.seed.started_at=2024-01-01T00:01:30+00:00
30
+ * ```
31
+ */
32
+ export async function getBootstrapStatus(statusFile, opts) {
33
+ const result = await exec(["cat", statusFile], { ...opts, quiet: true });
34
+ if (!result.ok) {
35
+ // File doesn't exist or isn't readable
36
+ return {
37
+ status: "started",
38
+ phases: {},
39
+ raw: "",
40
+ };
41
+ }
42
+ return parseBootstrapStatus(result.stdout);
43
+ }
44
+ /**
45
+ * Parse bootstrap status from file content.
46
+ */
47
+ export function parseBootstrapStatus(content) {
48
+ const phases = {};
49
+ const data = {};
50
+ // Parse key=value lines
51
+ for (const line of content.trim().split("\n")) {
52
+ if (!line || !line.includes("="))
53
+ continue;
54
+ const [key, ...valueParts] = line.split("=");
55
+ const value = valueParts.join("=").trim();
56
+ data[key] = value;
57
+ }
58
+ // Extract overall status
59
+ const status = (data.status || "started");
60
+ const startedAt = data.started_at;
61
+ const completedAt = data.completed_at;
62
+ const source = data.source;
63
+ // Extract phases
64
+ for (const [key, value] of Object.entries(data)) {
65
+ if (!key.startsWith("phase."))
66
+ continue;
67
+ // phase.{name}.{field}
68
+ const parts = key.split(".");
69
+ if (parts.length < 3)
70
+ continue;
71
+ const [, phaseName, field] = parts;
72
+ if (!phases[phaseName]) {
73
+ phases[phaseName] = { name: phaseName, status: "pending" };
74
+ }
75
+ switch (field) {
76
+ case "status":
77
+ phases[phaseName].status = value;
78
+ break;
79
+ case "started_at":
80
+ phases[phaseName].startedAt = value;
81
+ break;
82
+ case "completed_at":
83
+ phases[phaseName].completedAt = value;
84
+ break;
85
+ case "error":
86
+ phases[phaseName].error = value;
87
+ break;
88
+ }
89
+ }
90
+ return {
91
+ status,
92
+ startedAt,
93
+ completedAt,
94
+ source,
95
+ phases,
96
+ raw: content,
97
+ };
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Status file writing
101
+ // ---------------------------------------------------------------------------
102
+ /**
103
+ * Write initial bootstrap status file.
104
+ */
105
+ export async function initBootstrap(statusFile, source, opts) {
106
+ const now = new Date().toISOString();
107
+ const content = `status=started\nstarted_at=${now}\nsource=${source}\n`;
108
+ return writeFile(statusFile, content, opts);
109
+ }
110
+ /**
111
+ * Mark a phase as started.
112
+ */
113
+ export async function startPhase(statusFile, phase, opts) {
114
+ const now = new Date().toISOString();
115
+ const line = `phase.${phase}.status=running\nphase.${phase}.started_at=${now}\n`;
116
+ return writeFile(statusFile, line, { ...opts, append: true });
117
+ }
118
+ /**
119
+ * Mark a phase as complete.
120
+ */
121
+ export async function completePhase(statusFile, phase, opts) {
122
+ const now = new Date().toISOString();
123
+ const line = `phase.${phase}.status=complete\nphase.${phase}.completed_at=${now}\n`;
124
+ return writeFile(statusFile, line, { ...opts, append: true });
125
+ }
126
+ /**
127
+ * Mark a phase as failed.
128
+ */
129
+ export async function failPhase(statusFile, phase, error, opts) {
130
+ const now = new Date().toISOString();
131
+ const safeError = error.replace(/\n/g, " "); // Don't break the format
132
+ const line = `phase.${phase}.status=failed\nphase.${phase}.completed_at=${now}\nphase.${phase}.error=${safeError}\n`;
133
+ return writeFile(statusFile, line, { ...opts, append: true });
134
+ }
135
+ /**
136
+ * Mark entire bootstrap as complete.
137
+ */
138
+ export async function completeBootstrap(statusFile, opts) {
139
+ const now = new Date().toISOString();
140
+ const line = `status=complete\ncompleted_at=${now}\n`;
141
+ return writeFile(statusFile, line, { ...opts, append: true });
142
+ }
143
+ /**
144
+ * Mark bootstrap as failed.
145
+ */
146
+ export async function failBootstrap(statusFile, error, opts) {
147
+ const now = new Date().toISOString();
148
+ const safeError = error.replace(/\n/g, " ");
149
+ const line = `status=failed\ncompleted_at=${now}\nerror=${safeError}\n`;
150
+ return writeFile(statusFile, line, { ...opts, append: true });
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Marker files
154
+ // ---------------------------------------------------------------------------
155
+ /**
156
+ * Check if a marker file exists.
157
+ * Use for simple completion flags (e.g. .seed-setup-complete).
158
+ */
159
+ export async function checkMarker(markerPath, opts) {
160
+ const result = await exec(["test", "-f", markerPath, "&&", "echo", "exists"], {
161
+ ...opts,
162
+ quiet: true,
163
+ });
164
+ return result.ok && result.stdout.trim() === "exists";
165
+ }
166
+ /**
167
+ * Create a marker file.
168
+ */
169
+ export async function setMarker(markerPath, opts) {
170
+ return exec(["touch", markerPath], opts);
171
+ }
172
+ /**
173
+ * Remove a marker file.
174
+ */
175
+ export async function removeMarker(markerPath, opts) {
176
+ return exec(["rm", "-f", markerPath], opts);
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // Polling
180
+ // ---------------------------------------------------------------------------
181
+ /**
182
+ * Poll until bootstrap completes or times out.
183
+ *
184
+ * Returns the final status (complete or failed/timeout).
185
+ */
186
+ export async function waitForBootstrap(statusFile, opts) {
187
+ const maxAttempts = opts.maxAttempts ?? 30;
188
+ const intervalMs = opts.intervalMs ?? 2000;
189
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
190
+ const status = await getBootstrapStatus(statusFile, opts);
191
+ if (status.status === "complete") {
192
+ return { completed: true, status };
193
+ }
194
+ if (status.status === "failed") {
195
+ return { completed: false, status };
196
+ }
197
+ opts.onProgress?.(attempt, status);
198
+ await sleep(intervalMs);
199
+ }
200
+ // Timeout - get final status for error info
201
+ const finalStatus = await getBootstrapStatus(statusFile, opts);
202
+ return { completed: false, status: finalStatus };
203
+ }
204
+ /**
205
+ * Poll until a marker file exists.
206
+ */
207
+ export async function waitForMarker(markerPath, opts) {
208
+ const maxAttempts = opts.maxAttempts ?? 30;
209
+ const intervalMs = opts.intervalMs ?? 2000;
210
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
211
+ const exists = await checkMarker(markerPath, opts);
212
+ if (exists) {
213
+ return { exists: true, timedOut: false };
214
+ }
215
+ opts.onProgress?.(attempt, { status: "running" });
216
+ await sleep(intervalMs);
217
+ }
218
+ return { exists: false, timedOut: true };
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Utilities
222
+ // ---------------------------------------------------------------------------
223
+ function sleep(ms) {
224
+ return new Promise((resolve) => setTimeout(resolve, ms));
225
+ }
@@ -0,0 +1,36 @@
1
+ export interface CloneOptions {
2
+ /** Repository URL (HTTPS or SSH) */
3
+ repo: string;
4
+ /** Target directory (defaults to repo name) */
5
+ dest?: string;
6
+ /** Clone specific branch, tag, or ref */
7
+ ref?: string;
8
+ /** Shallow clone depth (e.g. 1 for latest commit only) */
9
+ depth?: number;
10
+ /** Sparse checkout: only clone these paths */
11
+ sparse?: string[];
12
+ /** Working directory to clone into (defaults to cwd) */
13
+ cwd?: string;
14
+ }
15
+ export interface CloneResult {
16
+ /** Absolute path to the cloned repo */
17
+ path: string;
18
+ /** The ref that was checked out */
19
+ ref: string;
20
+ /** Short commit hash */
21
+ commit: string;
22
+ }
23
+ /**
24
+ * Composable git clone with support for shallow, sparse, branch, and directory options.
25
+ *
26
+ * @example
27
+ * // Basic clone
28
+ * await clone({ repo: "https://github.com/org/repo.git" });
29
+ *
30
+ * // Shallow clone of a specific branch into a custom dir
31
+ * await clone({ repo: "https://github.com/org/repo.git", ref: "main", depth: 1, dest: "my-repo" });
32
+ *
33
+ * // Sparse checkout — only pull specific subdirectories
34
+ * await clone({ repo: "https://github.com/org/repo.git", sparse: ["src/lib", "src/types"], depth: 1 });
35
+ */
36
+ export declare function clone(opts: CloneOptions): Promise<CloneResult>;
package/dist/clone.js ADDED
@@ -0,0 +1,74 @@
1
+ import { spawn } from "child_process";
2
+ /**
3
+ * Composable git clone with support for shallow, sparse, branch, and directory options.
4
+ *
5
+ * @example
6
+ * // Basic clone
7
+ * await clone({ repo: "https://github.com/org/repo.git" });
8
+ *
9
+ * // Shallow clone of a specific branch into a custom dir
10
+ * await clone({ repo: "https://github.com/org/repo.git", ref: "main", depth: 1, dest: "my-repo" });
11
+ *
12
+ * // Sparse checkout — only pull specific subdirectories
13
+ * await clone({ repo: "https://github.com/org/repo.git", sparse: ["src/lib", "src/types"], depth: 1 });
14
+ */
15
+ export async function clone(opts) {
16
+ const { repo, dest, ref, depth, sparse, cwd } = opts;
17
+ const repoName = dest ?? repoNameFrom(repo);
18
+ const targetDir = cwd ? `${cwd}/${repoName}` : repoName;
19
+ const args = ["git", "clone"];
20
+ if (depth)
21
+ args.push("--depth", String(depth));
22
+ if (ref && !sparse)
23
+ args.push("--branch", ref);
24
+ if (sparse)
25
+ args.push("--no-checkout", "--filter=blob:none");
26
+ args.push(repo, targetDir);
27
+ await run(args, cwd);
28
+ if (sparse) {
29
+ await run(["git", "sparse-checkout", "init", "--cone"], targetDir);
30
+ await run(["git", "sparse-checkout", "set", ...sparse], targetDir);
31
+ if (ref) {
32
+ await run(["git", "checkout", ref], targetDir);
33
+ }
34
+ else {
35
+ await run(["git", "checkout"], targetDir);
36
+ }
37
+ }
38
+ const commit = (await run(["git", "rev-parse", "--short", "HEAD"], targetDir)).trim();
39
+ const checkedRef = (await run(["git", "rev-parse", "--abbrev-ref", "HEAD"], targetDir)).trim();
40
+ const absPath = (await run(["git", "rev-parse", "--show-toplevel"], targetDir)).trim();
41
+ return { path: absPath, ref: checkedRef, commit };
42
+ }
43
+ /** Extract repo name from a git URL */
44
+ function repoNameFrom(url) {
45
+ return url.replace(/\.git$/, "").split("/").pop();
46
+ }
47
+ /** Run a command and return stdout, throw on failure */
48
+ async function run(args, cwd) {
49
+ return new Promise((resolve, reject) => {
50
+ const proc = spawn(args[0], args.slice(1), {
51
+ cwd: cwd || undefined,
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+ let stdout = "";
55
+ let stderr = "";
56
+ proc.stdout?.on("data", (data) => {
57
+ stdout += data.toString();
58
+ });
59
+ proc.stderr?.on("data", (data) => {
60
+ stderr += data.toString();
61
+ });
62
+ proc.on("close", (code) => {
63
+ if (code === 0) {
64
+ resolve(stdout);
65
+ }
66
+ else {
67
+ reject(new Error(`${args.join(" ")} failed (exit ${code}): ${stderr}`));
68
+ }
69
+ });
70
+ proc.on("error", (err) => {
71
+ reject(new Error(`${args.join(" ")} failed: ${err.message}`));
72
+ });
73
+ });
74
+ }