@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 +21 -0
- package/README.md +172 -0
- package/dist/bootstrap.d.ts +114 -0
- package/dist/bootstrap.js +225 -0
- package/dist/clone.d.ts +36 -0
- package/dist/clone.js +74 -0
- package/dist/device-auth.d.ts +85 -0
- package/dist/device-auth.js +176 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/runtime.d.ts +60 -0
- package/dist/runtime.js +122 -0
- package/dist/sudo.d.ts +57 -0
- package/dist/sudo.js +235 -0
- package/dist/systemd.d.ts +114 -0
- package/dist/systemd.js +186 -0
- package/package.json +75 -0
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
|
+
}
|
package/dist/clone.d.ts
ADDED
|
@@ -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
|
+
}
|