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.
@@ -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
- return [
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
- ` StrictHostKeyChecking no`,
20
- ` UserKnownHostsFile ${knownHostsFile}`,
21
- endMarker,
22
- ].join('\n');
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
- if (!sshHost || !sshPrivateKey || !sshUser) {
71
- this.error('devEnvSshHost, devEnvSshPrivateKey, and devEnvSshUser must be set in the workspace environment');
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
- const newBlock = buildSshConfigBlock({ host, hostAlias, keyFilePath, sshUser });
91
- const updatedConfig = upsertSshConfigBlock(existingConfig, newBlock, hostAlias);
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
- this.log(` SSH Host: ${sshUser}@${host}`);
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
- - always-on (default): a long-lived EC2 polls hereya-cloud 24/7.
14
- - ephemeral: ASG scaled to 0 with a broker Lambda + OIDC for on-demand wake.`;
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: 'always-on',
20
- description: 'executor mode: always-on (legacy long-lived EC2) or ephemeral (ASG min=0/max=1 with broker Lambda)',
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
- // Move files from temp folder to destination
49
- const files = await fs.readdir(tmpFolder);
50
- await Promise.all(files.map(async (file) => {
51
- if (file !== '.git') {
52
- await fs.rename(path.join(tmpFolder, file), path.join(destPath, file));
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
- await fs.mkdir(destPath, { recursive: true });
17
- // Initialize simple-git
18
- const git = simpleGit();
19
- // Parse version from URL if present (format: url#version)
20
- const [repoUrl, version] = pkgUrl.split('#');
21
- if (version) {
22
- // Clone repository and checkout specific version tag
23
- await git.clone(repoUrl, destPath, ['--no-checkout']);
24
- const repoGit = simpleGit(destPath);
25
- try {
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
- // If v-prefixed tag doesn't exist, try without prefix
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
- // If neither tag exists, fail with clear error
36
- throw new Error(`Version '${version}' not found in repository ${repoUrl}. Available tags can be found at ${repoUrl}/tags`);
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
- else {
41
- // Clone repository at default branch
42
- await git.clone(pkgUrl, destPath, ['--depth=1']);
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
  }