hereya-cli 0.95.4 → 0.96.0
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/README.md +73 -72
- package/dist/commands/devenv/config/index.d.ts +12 -0
- package/dist/commands/devenv/config/index.js +66 -13
- package/dist/commands/devenv/ssh-proxy/index.d.ts +27 -0
- package/dist/commands/devenv/ssh-proxy/index.js +80 -0
- package/dist/commands/workspace/executor/install/index.js +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/package/cloud.js +34 -9
- package/dist/lib/package/github.js +65 -21
- package/oclif.manifest.json +277 -244
- package/package.json +1 -1
|
@@ -6,20 +6,43 @@ import { getBackend } from '../../../backend/index.js';
|
|
|
6
6
|
import { getExecutorForWorkspace } from '../../../executor/context.js';
|
|
7
7
|
import { setKeyFilePermissions, setSshDirPermissions } from '../../../lib/ssh-utils.js';
|
|
8
8
|
export function buildSshConfigBlock(options) {
|
|
9
|
-
const { host, hostAlias, keyFilePath, sshUser } = options;
|
|
9
|
+
const { host, hostAlias, keyFilePath, proxyCommand, sshUser } = options;
|
|
10
10
|
const knownHostsFile = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
11
11
|
const beginMarker = `# hereya-managed:${hostAlias} BEGIN`;
|
|
12
12
|
const endMarker = `# hereya-managed:${hostAlias} END`;
|
|
13
|
-
|
|
13
|
+
// On-demand devenvs (proxyCommand provided) need a placeholder HostName —
|
|
14
|
+
// ssh requires HostName to start a connection but the actual address is
|
|
15
|
+
// resolved by ProxyCommand at connect time. The alias itself is fine as
|
|
16
|
+
// a placeholder; ProxyCommand ignores %h.
|
|
17
|
+
const lines = [
|
|
14
18
|
beginMarker,
|
|
15
19
|
`Host ${hostAlias}`,
|
|
16
20
|
` HostName ${host}`,
|
|
17
21
|
` User ${sshUser}`,
|
|
18
22
|
` IdentityFile ${keyFilePath}`,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
];
|
|
24
|
+
if (proxyCommand) {
|
|
25
|
+
lines.push(` ProxyCommand ${proxyCommand}`);
|
|
26
|
+
}
|
|
27
|
+
lines.push(` StrictHostKeyChecking no`, ` UserKnownHostsFile ${knownHostsFile}`, endMarker);
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the path to the hereya binary so we can bake an absolute
|
|
32
|
+
* ProxyCommand into the SSH config. SSH invokes ProxyCommand without
|
|
33
|
+
* inheriting the user's interactive shell PATH (it uses /bin/sh and the
|
|
34
|
+
* stripped login env), so a bare `hereya` often won't resolve. Prefer
|
|
35
|
+
* `process.argv[1]` (the actual binary that ran us) — that path is what's
|
|
36
|
+
* already on the user's machine and just worked. Fall back to `hereya` if
|
|
37
|
+
* argv[1] is missing for any reason; users can fix their PATH or re-run
|
|
38
|
+
* `devenv config` from a binary they want the SSH proxy to use.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveHereyaBinForProxyCommand() {
|
|
41
|
+
const argv1 = process.argv[1];
|
|
42
|
+
if (argv1 && argv1.length > 0) {
|
|
43
|
+
return argv1;
|
|
44
|
+
}
|
|
45
|
+
return 'hereya';
|
|
23
46
|
}
|
|
24
47
|
export function upsertSshConfigBlock(existingConfig, newBlock, hostAlias) {
|
|
25
48
|
const beginMarker = `# hereya-managed:${hostAlias} BEGIN`;
|
|
@@ -37,7 +60,7 @@ export function upsertSshConfigBlock(existingConfig, newBlock, hostAlias) {
|
|
|
37
60
|
return existingConfig.trimEnd() + '\n\n' + newBlock + '\n';
|
|
38
61
|
}
|
|
39
62
|
export default class DevenvConfig extends Command {
|
|
40
|
-
static description = 'Configure SSH for a dev environment and display connection details.';
|
|
63
|
+
static description = 'Configure SSH for a dev environment and display connection details. On-demand devenvs use a ProxyCommand that wakes the instance on first connection.';
|
|
41
64
|
static examples = [
|
|
42
65
|
'<%= config.bin %> <%= command.id %> -w my-workspace',
|
|
43
66
|
];
|
|
@@ -67,10 +90,15 @@ export default class DevenvConfig extends Command {
|
|
|
67
90
|
const sshPrivateKey = env.devEnvSshPrivateKey;
|
|
68
91
|
const sshUser = env.devEnvSshUser;
|
|
69
92
|
const sshHostDns = env.devEnvSshHostDns;
|
|
70
|
-
|
|
71
|
-
|
|
93
|
+
const wakeUrl = env.devEnvWakeUrl;
|
|
94
|
+
if (!sshPrivateKey || !sshUser) {
|
|
95
|
+
this.error('devEnvSshPrivateKey and devEnvSshUser must be set in the workspace environment');
|
|
96
|
+
}
|
|
97
|
+
// Always-on devenvs need a cached host; on-demand devenvs resolve via
|
|
98
|
+
// the broker so devEnvSshHost is just a hint (and is often stale).
|
|
99
|
+
if (!wakeUrl && !sshHost) {
|
|
100
|
+
this.error('devEnvSshHost must be set in the workspace environment for always-on devenvs');
|
|
72
101
|
}
|
|
73
|
-
const host = sshHostDns || sshHost;
|
|
74
102
|
const sshDir = path.join(os.homedir(), '.ssh');
|
|
75
103
|
const sanitizedWorkspace = workspace.replaceAll('/', '-');
|
|
76
104
|
const keyFilePath = path.join(sshDir, `hereya-devenv-${sanitizedWorkspace}`);
|
|
@@ -87,15 +115,40 @@ export default class DevenvConfig extends Command {
|
|
|
87
115
|
catch {
|
|
88
116
|
// File doesn't exist yet, that's fine
|
|
89
117
|
}
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
// For on-demand devenvs, emit a ProxyCommand that calls back into this
|
|
119
|
+
// CLI to wake the box and resolve the live IP every connection. The
|
|
120
|
+
// cached devEnvSshHost would otherwise drift on every stop/start (EC2
|
|
121
|
+
// reassigns public IPs). The HostName field still has to be present
|
|
122
|
+
// for ssh to even try the connection — use the alias as a placeholder
|
|
123
|
+
// since ProxyCommand ignores %h.
|
|
124
|
+
const isOnDemand = Boolean(wakeUrl);
|
|
125
|
+
const block = isOnDemand
|
|
126
|
+
? buildSshConfigBlock({
|
|
127
|
+
host: hostAlias,
|
|
128
|
+
hostAlias,
|
|
129
|
+
keyFilePath,
|
|
130
|
+
proxyCommand: `${resolveHereyaBinForProxyCommand()} devenv ssh-proxy -w ${workspace}`,
|
|
131
|
+
sshUser,
|
|
132
|
+
})
|
|
133
|
+
: buildSshConfigBlock({
|
|
134
|
+
host: sshHostDns || sshHost,
|
|
135
|
+
hostAlias,
|
|
136
|
+
keyFilePath,
|
|
137
|
+
sshUser,
|
|
138
|
+
});
|
|
139
|
+
const updatedConfig = upsertSshConfigBlock(existingConfig, block, hostAlias);
|
|
92
140
|
await fs.writeFile(sshConfigPath, updatedConfig, { mode: 0o600 });
|
|
93
141
|
this.log('SSH connection configured successfully.');
|
|
94
142
|
this.log('');
|
|
95
143
|
this.log('Fill in the Claude Code SSH connection form with:');
|
|
96
144
|
this.log('');
|
|
97
145
|
this.log(` Name: ${workspace}`);
|
|
98
|
-
|
|
146
|
+
if (isOnDemand) {
|
|
147
|
+
this.log(` SSH Host: ${hostAlias} (on-demand; wake handled automatically via ProxyCommand)`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
this.log(` SSH Host: ${sshUser}@${sshHostDns || sshHost}`);
|
|
151
|
+
}
|
|
99
152
|
this.log(` SSH Port: 22`);
|
|
100
153
|
this.log(` Identity File: ${keyFilePath}`);
|
|
101
154
|
this.log('');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
/**
|
|
3
|
+
* Hidden command used as `ProxyCommand` from the SSH config block written
|
|
4
|
+
* by `hereya devenv config`. Wakes the devenv (on-demand path) via the
|
|
5
|
+
* broker, resolves the live IP, then pipes stdin/stdout to <host>:22 so
|
|
6
|
+
* any SSH client (plain `ssh`, VS Code Remote SSH, Cursor, etc.) connects
|
|
7
|
+
* through the resolved address transparently.
|
|
8
|
+
*
|
|
9
|
+
* Equivalent to `ssh -W %h:%p` semantics, except %h/%p are resolved here
|
|
10
|
+
* (host comes from the broker, port is fixed at 22). Uses Node's built-in
|
|
11
|
+
* `net` module so we don't depend on an external `nc`/`ncat` being on PATH.
|
|
12
|
+
*/
|
|
13
|
+
export default class DevenvSshProxy extends Command {
|
|
14
|
+
static description: string;
|
|
15
|
+
static flags: {
|
|
16
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
};
|
|
18
|
+
static hidden: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Open a TCP socket to <host>:<port> and pipe between stdin/stdout and
|
|
21
|
+
* the socket — the canonical SSH ProxyCommand contract. Resolves when
|
|
22
|
+
* the SSH client closes (EOF on stdin / socket end), with the appropriate
|
|
23
|
+
* exit code on error.
|
|
24
|
+
*/
|
|
25
|
+
protected pipeToHost(host: string, port: number): Promise<void>;
|
|
26
|
+
run(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import * as net from 'node:net';
|
|
3
|
+
import { getBackend } from '../../../backend/index.js';
|
|
4
|
+
import { getExecutorForWorkspace } from '../../../executor/context.js';
|
|
5
|
+
import { resolveDevenvSshHost } from '../../../lib/devenv-wake.js';
|
|
6
|
+
/**
|
|
7
|
+
* Hidden command used as `ProxyCommand` from the SSH config block written
|
|
8
|
+
* by `hereya devenv config`. Wakes the devenv (on-demand path) via the
|
|
9
|
+
* broker, resolves the live IP, then pipes stdin/stdout to <host>:22 so
|
|
10
|
+
* any SSH client (plain `ssh`, VS Code Remote SSH, Cursor, etc.) connects
|
|
11
|
+
* through the resolved address transparently.
|
|
12
|
+
*
|
|
13
|
+
* Equivalent to `ssh -W %h:%p` semantics, except %h/%p are resolved here
|
|
14
|
+
* (host comes from the broker, port is fixed at 22). Uses Node's built-in
|
|
15
|
+
* `net` module so we don't depend on an external `nc`/`ncat` being on PATH.
|
|
16
|
+
*/
|
|
17
|
+
export default class DevenvSshProxy extends Command {
|
|
18
|
+
static description = 'Internal: SSH ProxyCommand for on-demand devenvs (wake + connect).';
|
|
19
|
+
static flags = {
|
|
20
|
+
workspace: Flags.string({
|
|
21
|
+
char: 'w',
|
|
22
|
+
description: 'name of the workspace to proxy SSH for',
|
|
23
|
+
required: true,
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
static hidden = true;
|
|
27
|
+
/**
|
|
28
|
+
* Open a TCP socket to <host>:<port> and pipe between stdin/stdout and
|
|
29
|
+
* the socket — the canonical SSH ProxyCommand contract. Resolves when
|
|
30
|
+
* the SSH client closes (EOF on stdin / socket end), with the appropriate
|
|
31
|
+
* exit code on error.
|
|
32
|
+
*/
|
|
33
|
+
pipeToHost(host, port) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const socket = net.createConnection({ host, port });
|
|
36
|
+
socket.once('error', (err) => {
|
|
37
|
+
process.stderr.write(`hereya devenv ssh-proxy: failed to connect to ${host}:${port}: ${err.message}\n`);
|
|
38
|
+
reject(err);
|
|
39
|
+
});
|
|
40
|
+
socket.once('connect', () => {
|
|
41
|
+
process.stdin.pipe(socket);
|
|
42
|
+
socket.pipe(process.stdout);
|
|
43
|
+
});
|
|
44
|
+
socket.once('end', () => {
|
|
45
|
+
resolve();
|
|
46
|
+
});
|
|
47
|
+
socket.once('close', () => {
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async run() {
|
|
53
|
+
const { flags } = await this.parse(DevenvSshProxy);
|
|
54
|
+
const { workspace } = flags;
|
|
55
|
+
const backend = await getBackend();
|
|
56
|
+
const getWorkspaceEnvOutput = await backend.getWorkspaceEnv({ workspace });
|
|
57
|
+
if (!getWorkspaceEnvOutput.success) {
|
|
58
|
+
this.error(getWorkspaceEnvOutput.reason);
|
|
59
|
+
}
|
|
60
|
+
let { env } = getWorkspaceEnvOutput;
|
|
61
|
+
const executor$ = await getExecutorForWorkspace(workspace);
|
|
62
|
+
if (!executor$.success) {
|
|
63
|
+
this.error(executor$.reason);
|
|
64
|
+
}
|
|
65
|
+
const { executor } = executor$;
|
|
66
|
+
env = await executor.resolveEnvValues({ env });
|
|
67
|
+
let host;
|
|
68
|
+
try {
|
|
69
|
+
host = await resolveDevenvSshHost(env);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
// Print to stderr so SSH surfaces it, then exit non-zero so SSH
|
|
74
|
+
// reports a clean failure to the user.
|
|
75
|
+
process.stderr.write(`hereya devenv ssh-proxy: ${message}\n`);
|
|
76
|
+
this.exit(1);
|
|
77
|
+
}
|
|
78
|
+
await this.pipeToHost(host, 22);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -10,14 +10,14 @@ export default class WorkspaceExecutorInstall extends Command {
|
|
|
10
10
|
static description = `Install a remote executor into a workspace.
|
|
11
11
|
|
|
12
12
|
Provisions hereya/remote-executor-aws. Two modes are supported:
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
- ephemeral (default): ASG scaled to 0 with a broker Lambda + OIDC for on-demand wake.
|
|
14
|
+
- always-on: a long-lived EC2 polls hereya-cloud 24/7 (legacy).`;
|
|
15
15
|
static flags = {
|
|
16
16
|
debug: Flags.boolean({ default: false, description: 'enable debug mode' }),
|
|
17
17
|
force: Flags.boolean({ char: 'f', default: false, description: 'force reinstall even if executor is already installed' }),
|
|
18
18
|
mode: Flags.string({
|
|
19
|
-
default: '
|
|
20
|
-
description: 'executor mode:
|
|
19
|
+
default: 'ephemeral',
|
|
20
|
+
description: 'executor mode: ephemeral (ASG min=0/max=1 with broker Lambda) or always-on (legacy long-lived EC2)',
|
|
21
21
|
options: ['ephemeral', 'always-on'],
|
|
22
22
|
}),
|
|
23
23
|
parameter: Flags.string({
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type GetInfrastructureFn, type GetWorkspaceEnvFn, type MintInstallationTokenFn, type ResolveEnvProviders, resolveEnvValues, type ResolveEnvValuesInput, type ResolveEnvValuesOutput, resolveGithubAppMarkers, type ResolveSimpleEnvFn, } from './executor/resolve-env.js';
|
|
2
2
|
export { InfrastructureType } from './infrastructure/common.js';
|
|
3
3
|
export { awsProviderFactory, getInfrastructure, type GetInfrastructureInput, type GetInfrastructureOutput, type InfrastructureProviderFactory, registerInfrastructureProvider, resetInfrastructureProviders, } from './infrastructure/registry.js';
|
|
4
|
+
export { mintInstallationToken, type MintInstallationTokenInput, } from './lib/github-app.js';
|
|
4
5
|
export { run } from '@oclif/core';
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
export { resolveEnvValues, resolveGithubAppMarkers, } from './executor/resolve-env.js';
|
|
6
6
|
export { InfrastructureType } from './infrastructure/common.js';
|
|
7
7
|
export { awsProviderFactory, getInfrastructure, registerInfrastructureProvider, resetInfrastructureProviders, } from './infrastructure/registry.js';
|
|
8
|
+
export { mintInstallationToken, } from './lib/github-app.js';
|
|
8
9
|
export { run } from '@oclif/core';
|
|
@@ -17,7 +17,6 @@ export class CloudPackageManager {
|
|
|
17
17
|
if (await isNotEmpty(destPath)) {
|
|
18
18
|
return destPath;
|
|
19
19
|
}
|
|
20
|
-
await fs.mkdir(destPath, { recursive: true });
|
|
21
20
|
const tmpFolder = path.join(os.homedir(), '.hereya', 'downloads', randomUUID());
|
|
22
21
|
try {
|
|
23
22
|
// Use simple-git to clone the repository at specific commit
|
|
@@ -45,15 +44,41 @@ export class CloudPackageManager {
|
|
|
45
44
|
throw new Error(`Checksum mismatch for package ${registryPackage.name}@${registryPackage.version}. Expected: ${registryPackage.sha256}, Got: ${actualChecksum}`);
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// Drop .git so the published artifact is clean — and so the directory
|
|
48
|
+
// rename below carries only the package contents.
|
|
49
|
+
await fs.rm(path.join(tmpFolder, '.git'), { force: true, recursive: true });
|
|
50
|
+
// Atomic publish: rename the whole tmpFolder onto destPath in one
|
|
51
|
+
// syscall. POSIX rename succeeds when destPath doesn't exist or is an
|
|
52
|
+
// empty directory; if a concurrent install already populated destPath
|
|
53
|
+
// (peer won the race), we get ENOTEMPTY/EEXIST and treat that as
|
|
54
|
+
// success — their result is valid because they ran the same
|
|
55
|
+
// checksum-validated clone we did.
|
|
56
|
+
//
|
|
57
|
+
// Pre-fix: we mkdir'd destPath then renamed each file individually,
|
|
58
|
+
// so two concurrent jobs racing on the same package both passed
|
|
59
|
+
// isNotEmpty(destPath) === false, both cloned into separate
|
|
60
|
+
// tmpFolders, and both tried to rename `<tmp>/lib` → `<destPath>/lib`.
|
|
61
|
+
// First wins; second crashed with EEXIST and the executor reported
|
|
62
|
+
// the job failed even though the package was correctly installed
|
|
63
|
+
// by the peer. Concurrency 20 in the executor amplified this
|
|
64
|
+
// anytime more than one job for the same workspace ran in parallel.
|
|
65
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
66
|
+
try {
|
|
67
|
+
await fs.rename(tmpFolder, destPath);
|
|
68
|
+
}
|
|
69
|
+
catch (renameError) {
|
|
70
|
+
const { code } = renameError;
|
|
71
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
|
72
|
+
// Peer install won. Discard our copy.
|
|
73
|
+
await fs.rm(tmpFolder, { force: true, recursive: true });
|
|
74
|
+
if (!(await isNotEmpty(destPath))) {
|
|
75
|
+
throw new Error(`Concurrent install for ${registryPackage.name}@${registryPackage.version} reported as won but left ${destPath} empty`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
throw renameError;
|
|
53
80
|
}
|
|
54
|
-
}
|
|
55
|
-
// Clean up temp folder
|
|
56
|
-
await fs.rm(tmpFolder, { force: true, recursive: true });
|
|
81
|
+
}
|
|
57
82
|
}
|
|
58
83
|
catch (error) {
|
|
59
84
|
// Clean up on error
|
|
@@ -13,33 +13,77 @@ export class GitHubPackageManager {
|
|
|
13
13
|
if (await isNotEmpty(destPath)) {
|
|
14
14
|
return destPath;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// Try to checkout the version as a tag (e.g., v1.2.3 or 1.2.3)
|
|
27
|
-
await repoGit.checkout(`v${version}`);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
16
|
+
const tmpFolder = path.join(os.homedir(), '.hereya', 'downloads', randomUUID());
|
|
17
|
+
try {
|
|
18
|
+
// Initialize simple-git
|
|
19
|
+
const git = simpleGit();
|
|
20
|
+
// Parse version from URL if present (format: url#version)
|
|
21
|
+
const [repoUrl, version] = pkgUrl.split('#');
|
|
22
|
+
if (version) {
|
|
23
|
+
// Clone repository and checkout specific version tag
|
|
24
|
+
await git.clone(repoUrl, tmpFolder, ['--no-checkout']);
|
|
25
|
+
const repoGit = simpleGit(tmpFolder);
|
|
30
26
|
try {
|
|
31
|
-
//
|
|
32
|
-
await repoGit.checkout(version);
|
|
27
|
+
// Try to checkout the version as a tag (e.g., v1.2.3 or 1.2.3)
|
|
28
|
+
await repoGit.checkout(`v${version}`);
|
|
33
29
|
}
|
|
34
30
|
catch {
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
try {
|
|
32
|
+
// If v-prefixed tag doesn't exist, try without prefix
|
|
33
|
+
await repoGit.checkout(version);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// If neither tag exists, fail with clear error
|
|
37
|
+
throw new Error(`Version '${version}' not found in repository ${repoUrl}. Available tags can be found at ${repoUrl}/tags`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Clone repository at default branch
|
|
43
|
+
await git.clone(pkgUrl, tmpFolder, ['--depth=1']);
|
|
44
|
+
}
|
|
45
|
+
// Drop .git so the published artifact is clean — and so the directory
|
|
46
|
+
// rename below carries only the package contents.
|
|
47
|
+
await fs.rm(path.join(tmpFolder, '.git'), { force: true, recursive: true });
|
|
48
|
+
// Atomic publish: rename the whole tmpFolder onto destPath in one
|
|
49
|
+
// syscall. POSIX rename succeeds when destPath doesn't exist or is an
|
|
50
|
+
// empty directory; if a concurrent install already populated destPath
|
|
51
|
+
// (peer won the race), we get ENOTEMPTY/EEXIST and treat that as
|
|
52
|
+
// success — their result is valid because they ran the same clone we
|
|
53
|
+
// did.
|
|
54
|
+
//
|
|
55
|
+
// Pre-fix: we mkdir'd destPath then cloned directly into it, so two
|
|
56
|
+
// concurrent jobs racing on the same package both passed
|
|
57
|
+
// isNotEmpty(destPath) === false and both tried to clone into the same
|
|
58
|
+
// directory. simple-git fails when the target is non-empty, and the
|
|
59
|
+
// executor reported the job failed even though one peer succeeded.
|
|
60
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
61
|
+
try {
|
|
62
|
+
await fs.rename(tmpFolder, destPath);
|
|
63
|
+
}
|
|
64
|
+
catch (renameError) {
|
|
65
|
+
const { code } = renameError;
|
|
66
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
|
67
|
+
// Peer install won. Discard our copy.
|
|
68
|
+
await fs.rm(tmpFolder, { force: true, recursive: true });
|
|
69
|
+
if (!(await isNotEmpty(destPath))) {
|
|
70
|
+
throw new Error(`Concurrent install for ${pkgUrl} reported as won but left ${destPath} empty`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
throw renameError;
|
|
37
75
|
}
|
|
38
76
|
}
|
|
39
77
|
}
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Clean up on error
|
|
80
|
+
try {
|
|
81
|
+
await fs.rm(tmpFolder, { force: true, recursive: true });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ignore cleanup errors
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
43
87
|
}
|
|
44
88
|
return destPath;
|
|
45
89
|
}
|