hereya-cli 0.93.1 → 0.94.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 +69 -67
- package/dist/commands/devenv/install/index.d.ts +1 -0
- package/dist/commands/devenv/install/index.js +13 -3
- package/dist/commands/devenv/project/init/index.js +12 -5
- package/dist/commands/devenv/project/uninit/index.js +12 -5
- package/dist/commands/devenv/ssh/index.d.ts +0 -3
- package/dist/commands/devenv/ssh/index.js +10 -146
- package/dist/commands/init/index.js +1 -1
- package/dist/lib/devenv-wake.d.ts +13 -0
- package/dist/lib/devenv-wake.js +153 -0
- package/dist/lib/hereya-token.d.ts +25 -1
- package/dist/lib/hereya-token.js +58 -2
- package/oclif.manifest.json +146 -138
- package/package.json +1 -1
|
@@ -6,6 +6,8 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { getBackend } from '../../../../backend/index.js';
|
|
8
8
|
import { getExecutorForWorkspace } from '../../../../executor/context.js';
|
|
9
|
+
import { resolveDevenvSshHost } from '../../../../lib/devenv-wake.js';
|
|
10
|
+
import { getLogPath } from '../../../../lib/log.js';
|
|
9
11
|
import { setKeyFilePermissions } from '../../../../lib/ssh-utils.js';
|
|
10
12
|
export default class DevenvProjectInit extends Command {
|
|
11
13
|
static args = {
|
|
@@ -69,14 +71,19 @@ export default class DevenvProjectInit extends Command {
|
|
|
69
71
|
}
|
|
70
72
|
const { executor } = executor$;
|
|
71
73
|
env = await executor.resolveEnvValues({ env });
|
|
72
|
-
const sshHost = env.devEnvSshHost;
|
|
73
74
|
const sshPrivateKey = env.devEnvSshPrivateKey;
|
|
74
75
|
const sshUser = env.devEnvSshUser;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
if (!sshPrivateKey || !sshUser) {
|
|
77
|
+
this.error('devEnvSshPrivateKey and devEnvSshUser must be set in the workspace environment');
|
|
78
|
+
}
|
|
79
|
+
let host;
|
|
80
|
+
try {
|
|
81
|
+
host = await resolveDevenvSshHost(env);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
this.error(`Failed to wake dev environment: ${message}. See ${getLogPath()} for details.`);
|
|
78
86
|
}
|
|
79
|
-
const host = sshHostDns || sshHost;
|
|
80
87
|
const tempKeyPath = path.join(os.tmpdir(), `hereya-ssh-${randomUUID()}`);
|
|
81
88
|
try {
|
|
82
89
|
await fs.writeFile(tempKeyPath, sshPrivateKey);
|
|
@@ -6,6 +6,8 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { getBackend } from '../../../../backend/index.js';
|
|
8
8
|
import { getExecutorForWorkspace } from '../../../../executor/context.js';
|
|
9
|
+
import { resolveDevenvSshHost } from '../../../../lib/devenv-wake.js';
|
|
10
|
+
import { getLogPath } from '../../../../lib/log.js';
|
|
9
11
|
import { setKeyFilePermissions } from '../../../../lib/ssh-utils.js';
|
|
10
12
|
export default class DevenvProjectUninit extends Command {
|
|
11
13
|
static args = {
|
|
@@ -47,14 +49,19 @@ export default class DevenvProjectUninit extends Command {
|
|
|
47
49
|
}
|
|
48
50
|
const { executor } = executor$;
|
|
49
51
|
env = await executor.resolveEnvValues({ env });
|
|
50
|
-
const sshHost = env.devEnvSshHost;
|
|
51
52
|
const sshPrivateKey = env.devEnvSshPrivateKey;
|
|
52
53
|
const sshUser = env.devEnvSshUser;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (!sshPrivateKey || !sshUser) {
|
|
55
|
+
this.error('devEnvSshPrivateKey and devEnvSshUser must be set in the workspace environment');
|
|
56
|
+
}
|
|
57
|
+
let host;
|
|
58
|
+
try {
|
|
59
|
+
host = await resolveDevenvSshHost(env);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
this.error(`Failed to wake dev environment: ${message}. See ${getLogPath()} for details.`);
|
|
56
64
|
}
|
|
57
|
-
const host = sshHostDns || sshHost;
|
|
58
65
|
const tempKeyPath = path.join(os.tmpdir(), `hereya-ssh-${randomUUID()}`);
|
|
59
66
|
try {
|
|
60
67
|
await fs.writeFile(tempKeyPath, sshPrivateKey);
|
|
@@ -1,74 +1,14 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
-
import { Listr } from 'listr2';
|
|
3
2
|
import { spawnSync } from 'node:child_process';
|
|
4
3
|
import { randomUUID } from 'node:crypto';
|
|
5
4
|
import fs from 'node:fs/promises';
|
|
6
|
-
import { Socket } from 'node:net';
|
|
7
5
|
import os from 'node:os';
|
|
8
6
|
import path from 'node:path';
|
|
9
7
|
import { getBackend } from '../../../backend/index.js';
|
|
10
8
|
import { getExecutorForWorkspace } from '../../../executor/context.js';
|
|
9
|
+
import { resolveDevenvSshHost } from '../../../lib/devenv-wake.js';
|
|
11
10
|
import { getLogPath } from '../../../lib/log.js';
|
|
12
11
|
import { setKeyFilePermissions } from '../../../lib/ssh-utils.js';
|
|
13
|
-
// Polling cadence and overall timeout for the wake broker. Overridable via env
|
|
14
|
-
// vars purely to keep tests fast — the defaults are the production values.
|
|
15
|
-
function getWakePollIntervalMs() {
|
|
16
|
-
return Number(process.env.HEREYA_WAKE_POLL_INTERVAL_MS) || 4000;
|
|
17
|
-
}
|
|
18
|
-
function getWakeTimeoutMs() {
|
|
19
|
-
return Number(process.env.HEREYA_WAKE_TIMEOUT_MS) || 90_000;
|
|
20
|
-
}
|
|
21
|
-
// Probe budget for waiting on sshd after the EC2 instance reports running.
|
|
22
|
-
// EC2 returns state=running before sshd starts accepting connections; without
|
|
23
|
-
// this probe, the immediate `ssh` spawn races and gets ECONNREFUSED.
|
|
24
|
-
// Set HEREYA_SSH_READY_TIMEOUT_MS=0 to disable the probe entirely (tests).
|
|
25
|
-
function getSshReadyTimeoutMs() {
|
|
26
|
-
const raw = process.env.HEREYA_SSH_READY_TIMEOUT_MS;
|
|
27
|
-
if (raw === undefined)
|
|
28
|
-
return 60_000;
|
|
29
|
-
return Number(raw);
|
|
30
|
-
}
|
|
31
|
-
function getSshReadyAttemptIntervalMs() {
|
|
32
|
-
return Number(process.env.HEREYA_SSH_READY_ATTEMPT_INTERVAL_MS) || 2000;
|
|
33
|
-
}
|
|
34
|
-
function sleep(ms) {
|
|
35
|
-
return new Promise((resolve) => {
|
|
36
|
-
setTimeout(resolve, ms);
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
function probeTcp(host, port, timeoutMs) {
|
|
40
|
-
return new Promise((resolve) => {
|
|
41
|
-
const socket = new Socket();
|
|
42
|
-
let done = false;
|
|
43
|
-
const finish = (ok) => {
|
|
44
|
-
if (done)
|
|
45
|
-
return;
|
|
46
|
-
done = true;
|
|
47
|
-
socket.destroy();
|
|
48
|
-
resolve(ok);
|
|
49
|
-
};
|
|
50
|
-
socket.setTimeout(timeoutMs);
|
|
51
|
-
socket.once('connect', () => finish(true));
|
|
52
|
-
socket.once('timeout', () => finish(false));
|
|
53
|
-
socket.once('error', () => finish(false));
|
|
54
|
-
socket.connect(port, host);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
async function waitForSshReady(host, port, totalTimeoutMs, attemptIntervalMs) {
|
|
58
|
-
const start = Date.now();
|
|
59
|
-
// First attempt fires immediately.
|
|
60
|
-
while (Date.now() - start < totalTimeoutMs) {
|
|
61
|
-
// eslint-disable-next-line no-await-in-loop
|
|
62
|
-
const ok = await probeTcp(host, port, Math.min(2000, attemptIntervalMs));
|
|
63
|
-
if (ok)
|
|
64
|
-
return;
|
|
65
|
-
if (Date.now() - start + attemptIntervalMs >= totalTimeoutMs)
|
|
66
|
-
break;
|
|
67
|
-
// eslint-disable-next-line no-await-in-loop
|
|
68
|
-
await sleep(attemptIntervalMs);
|
|
69
|
-
}
|
|
70
|
-
throw new Error(`SSH at ${host}:${port} did not accept connections within ${totalTimeoutMs / 1000}s`);
|
|
71
|
-
}
|
|
72
12
|
export default class DevenvSsh extends Command {
|
|
73
13
|
static description = 'SSH into a dev environment instance.';
|
|
74
14
|
static examples = [
|
|
@@ -96,27 +36,19 @@ export default class DevenvSsh extends Command {
|
|
|
96
36
|
}
|
|
97
37
|
const { executor } = executor$;
|
|
98
38
|
env = await executor.resolveEnvValues({ env });
|
|
99
|
-
let sshHost = env.devEnvSshHost;
|
|
100
39
|
const sshPrivateKey = env.devEnvSshPrivateKey;
|
|
101
40
|
const sshUser = env.devEnvSshUser;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const wakeToken = env.devEnvWakeToken;
|
|
105
|
-
if (!sshHost || !sshPrivateKey || !sshUser) {
|
|
106
|
-
this.error('devEnvSshHost, devEnvSshPrivateKey, and devEnvSshUser must be set in the workspace environment');
|
|
41
|
+
if (!sshPrivateKey || !sshUser) {
|
|
42
|
+
this.error('devEnvSshPrivateKey and devEnvSshUser must be set in the workspace environment');
|
|
107
43
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
-
this.error(`Failed to wake dev environment: ${message}. See ${getLogPath()} for details.`);
|
|
117
|
-
}
|
|
44
|
+
let host;
|
|
45
|
+
try {
|
|
46
|
+
host = await resolveDevenvSshHost(env);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
this.error(`Failed to wake dev environment: ${message}. See ${getLogPath()} for details.`);
|
|
118
51
|
}
|
|
119
|
-
const host = sshHostDns || sshHost;
|
|
120
52
|
const tempKeyPath = path.join(os.tmpdir(), `hereya-ssh-${randomUUID()}`);
|
|
121
53
|
try {
|
|
122
54
|
await fs.writeFile(tempKeyPath, sshPrivateKey);
|
|
@@ -145,72 +77,4 @@ export default class DevenvSsh extends Command {
|
|
|
145
77
|
}
|
|
146
78
|
return Promise.resolve();
|
|
147
79
|
}
|
|
148
|
-
async fetchStatus(wakeUrl, wakeToken) {
|
|
149
|
-
const response = await fetch(`${wakeUrl}/status`, {
|
|
150
|
-
headers: { Authorization: `Bearer ${wakeToken}` },
|
|
151
|
-
method: 'GET',
|
|
152
|
-
});
|
|
153
|
-
if (!response.ok) {
|
|
154
|
-
const body = await response.text().catch(() => '');
|
|
155
|
-
throw new Error(`broker /status returned ${response.status}: ${body}`);
|
|
156
|
-
}
|
|
157
|
-
return response.json();
|
|
158
|
-
}
|
|
159
|
-
async postWake(wakeUrl, wakeToken) {
|
|
160
|
-
const response = await fetch(`${wakeUrl}/wake`, {
|
|
161
|
-
headers: { Authorization: `Bearer ${wakeToken}` },
|
|
162
|
-
method: 'POST',
|
|
163
|
-
});
|
|
164
|
-
if (!response.ok) {
|
|
165
|
-
const body = await response.text().catch(() => '');
|
|
166
|
-
throw new Error(`broker /wake returned ${response.status}: ${body}`);
|
|
167
|
-
}
|
|
168
|
-
return response.json();
|
|
169
|
-
}
|
|
170
|
-
async wakeAndPoll(wakeUrl, wakeToken) {
|
|
171
|
-
const pollIntervalMs = getWakePollIntervalMs();
|
|
172
|
-
const timeoutMs = getWakeTimeoutMs();
|
|
173
|
-
const sshReadyTimeoutMs = getSshReadyTimeoutMs();
|
|
174
|
-
const sshReadyAttemptIntervalMs = getSshReadyAttemptIntervalMs();
|
|
175
|
-
const task = new Listr([
|
|
176
|
-
{
|
|
177
|
-
task: async (ctx) => {
|
|
178
|
-
const initial = await this.fetchStatus(wakeUrl, wakeToken);
|
|
179
|
-
if (initial.state === 'running' && initial.host) {
|
|
180
|
-
ctx.host = initial.host;
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
await this.postWake(wakeUrl, wakeToken);
|
|
184
|
-
const start = Date.now();
|
|
185
|
-
let done = false;
|
|
186
|
-
while (!done) {
|
|
187
|
-
if (Date.now() - start >= timeoutMs) {
|
|
188
|
-
throw new Error(`timed out waiting for dev environment to start after ${timeoutMs / 1000}s`);
|
|
189
|
-
}
|
|
190
|
-
// eslint-disable-next-line no-await-in-loop
|
|
191
|
-
await sleep(pollIntervalMs);
|
|
192
|
-
// eslint-disable-next-line no-await-in-loop
|
|
193
|
-
const status = await this.fetchStatus(wakeUrl, wakeToken);
|
|
194
|
-
if (status.state === 'running' && status.host) {
|
|
195
|
-
ctx.host = status.host;
|
|
196
|
-
done = true;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
},
|
|
200
|
-
title: 'Waking dev environment',
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
enabled: () => sshReadyTimeoutMs > 0,
|
|
204
|
-
async task(ctx) {
|
|
205
|
-
await waitForSshReady(ctx.host, 22, sshReadyTimeoutMs, sshReadyAttemptIntervalMs);
|
|
206
|
-
},
|
|
207
|
-
title: 'Waiting for SSH to accept connections',
|
|
208
|
-
},
|
|
209
|
-
], { concurrent: false, ctx: { host: '' } });
|
|
210
|
-
const result = await task.run();
|
|
211
|
-
if (!result.host) {
|
|
212
|
-
throw new Error('broker did not return a host after wake');
|
|
213
|
-
}
|
|
214
|
-
return { host: result.host };
|
|
215
|
-
}
|
|
216
80
|
}
|
|
@@ -73,7 +73,7 @@ export default class Init extends Command {
|
|
|
73
73
|
if (!executor$.success)
|
|
74
74
|
throw new Error(executor$.reason);
|
|
75
75
|
const userParams = arrayOfStringToObject(flags.parameter);
|
|
76
|
-
const tokenResult = await hereyaTokenUtils.
|
|
76
|
+
const tokenResult = await hereyaTokenUtils.generatePersonalToken(`Template: ${args.project}`);
|
|
77
77
|
ctx.parameters = {
|
|
78
78
|
...userParams,
|
|
79
79
|
deployWorkspace: flags['deploy-workspace'],
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the SSH host to use for a devenv workspace.
|
|
3
|
+
*
|
|
4
|
+
* - On-demand devenvs (devEnvWakeUrl + devEnvWakeToken set): wake the instance
|
|
5
|
+
* if it's not already running, poll the broker until it reports a public IP,
|
|
6
|
+
* probe TCP :22 until sshd accepts connections, then return the fresh IP.
|
|
7
|
+
* - Always-on devenvs (no wake URL): return the cached devEnvSshHostDns or
|
|
8
|
+
* devEnvSshHost from workspace env unchanged. No wake, no probe.
|
|
9
|
+
*
|
|
10
|
+
* The Listr UI ("Waking dev environment", "Waiting for SSH to accept
|
|
11
|
+
* connections") only renders on the on-demand path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveDevenvSshHost(env: Record<string, string>): Promise<string>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Listr } from 'listr2';
|
|
2
|
+
import { Socket } from 'node:net';
|
|
3
|
+
// Polling cadence and overall timeout for the wake broker. Overridable via env
|
|
4
|
+
// vars purely to keep tests fast — the defaults are the production values.
|
|
5
|
+
function getWakePollIntervalMs() {
|
|
6
|
+
return Number(process.env.HEREYA_WAKE_POLL_INTERVAL_MS) || 4000;
|
|
7
|
+
}
|
|
8
|
+
function getWakeTimeoutMs() {
|
|
9
|
+
return Number(process.env.HEREYA_WAKE_TIMEOUT_MS) || 90_000;
|
|
10
|
+
}
|
|
11
|
+
// Probe budget for waiting on sshd after the EC2 instance reports running.
|
|
12
|
+
// EC2 returns state=running before sshd starts accepting connections; without
|
|
13
|
+
// this probe, the immediate ssh spawn races and gets ECONNREFUSED.
|
|
14
|
+
// Set HEREYA_SSH_READY_TIMEOUT_MS=0 to disable the probe entirely (tests).
|
|
15
|
+
function getSshReadyTimeoutMs() {
|
|
16
|
+
const raw = process.env.HEREYA_SSH_READY_TIMEOUT_MS;
|
|
17
|
+
if (raw === undefined)
|
|
18
|
+
return 60_000;
|
|
19
|
+
return Number(raw);
|
|
20
|
+
}
|
|
21
|
+
function getSshReadyAttemptIntervalMs() {
|
|
22
|
+
return Number(process.env.HEREYA_SSH_READY_ATTEMPT_INTERVAL_MS) || 2000;
|
|
23
|
+
}
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
setTimeout(resolve, ms);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function probeTcp(host, port, timeoutMs) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const socket = new Socket();
|
|
32
|
+
let done = false;
|
|
33
|
+
const finish = (ok) => {
|
|
34
|
+
if (done)
|
|
35
|
+
return;
|
|
36
|
+
done = true;
|
|
37
|
+
socket.destroy();
|
|
38
|
+
resolve(ok);
|
|
39
|
+
};
|
|
40
|
+
socket.setTimeout(timeoutMs);
|
|
41
|
+
socket.once('connect', () => finish(true));
|
|
42
|
+
socket.once('timeout', () => finish(false));
|
|
43
|
+
socket.once('error', () => finish(false));
|
|
44
|
+
socket.connect(port, host);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function waitForSshReady(host, port, totalTimeoutMs, attemptIntervalMs) {
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
while (Date.now() - start < totalTimeoutMs) {
|
|
50
|
+
// eslint-disable-next-line no-await-in-loop
|
|
51
|
+
const ok = await probeTcp(host, port, Math.min(2000, attemptIntervalMs));
|
|
52
|
+
if (ok)
|
|
53
|
+
return;
|
|
54
|
+
if (Date.now() - start + attemptIntervalMs >= totalTimeoutMs)
|
|
55
|
+
break;
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop
|
|
57
|
+
await sleep(attemptIntervalMs);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`SSH at ${host}:${port} did not accept connections within ${totalTimeoutMs / 1000}s`);
|
|
60
|
+
}
|
|
61
|
+
async function fetchStatus(wakeUrl, wakeToken) {
|
|
62
|
+
const response = await fetch(`${wakeUrl}/status`, {
|
|
63
|
+
headers: { Authorization: `Bearer ${wakeToken}` },
|
|
64
|
+
method: 'GET',
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const body = await response.text().catch(() => '');
|
|
68
|
+
throw new Error(`broker /status returned ${response.status}: ${body}`);
|
|
69
|
+
}
|
|
70
|
+
return response.json();
|
|
71
|
+
}
|
|
72
|
+
async function postWake(wakeUrl, wakeToken) {
|
|
73
|
+
const response = await fetch(`${wakeUrl}/wake`, {
|
|
74
|
+
headers: { Authorization: `Bearer ${wakeToken}` },
|
|
75
|
+
method: 'POST',
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
const body = await response.text().catch(() => '');
|
|
79
|
+
throw new Error(`broker /wake returned ${response.status}: ${body}`);
|
|
80
|
+
}
|
|
81
|
+
return response.json();
|
|
82
|
+
}
|
|
83
|
+
async function wakeAndProbe(wakeUrl, wakeToken) {
|
|
84
|
+
const pollIntervalMs = getWakePollIntervalMs();
|
|
85
|
+
const timeoutMs = getWakeTimeoutMs();
|
|
86
|
+
const sshReadyTimeoutMs = getSshReadyTimeoutMs();
|
|
87
|
+
const sshReadyAttemptIntervalMs = getSshReadyAttemptIntervalMs();
|
|
88
|
+
const task = new Listr([
|
|
89
|
+
{
|
|
90
|
+
async task(ctx) {
|
|
91
|
+
const initial = await fetchStatus(wakeUrl, wakeToken);
|
|
92
|
+
if (initial.state === 'running' && initial.host) {
|
|
93
|
+
ctx.host = initial.host;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await postWake(wakeUrl, wakeToken);
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
let done = false;
|
|
99
|
+
while (!done) {
|
|
100
|
+
if (Date.now() - start >= timeoutMs) {
|
|
101
|
+
throw new Error(`timed out waiting for dev environment to start after ${timeoutMs / 1000}s`);
|
|
102
|
+
}
|
|
103
|
+
// eslint-disable-next-line no-await-in-loop
|
|
104
|
+
await sleep(pollIntervalMs);
|
|
105
|
+
// eslint-disable-next-line no-await-in-loop
|
|
106
|
+
const status = await fetchStatus(wakeUrl, wakeToken);
|
|
107
|
+
if (status.state === 'running' && status.host) {
|
|
108
|
+
ctx.host = status.host;
|
|
109
|
+
done = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
title: 'Waking dev environment',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
enabled: () => sshReadyTimeoutMs > 0,
|
|
117
|
+
async task(ctx) {
|
|
118
|
+
await waitForSshReady(ctx.host, 22, sshReadyTimeoutMs, sshReadyAttemptIntervalMs);
|
|
119
|
+
},
|
|
120
|
+
title: 'Waiting for SSH to accept connections',
|
|
121
|
+
},
|
|
122
|
+
], { concurrent: false, ctx: { host: '' } });
|
|
123
|
+
const result = await task.run();
|
|
124
|
+
if (!result.host) {
|
|
125
|
+
throw new Error('broker did not return a host after wake');
|
|
126
|
+
}
|
|
127
|
+
return result.host;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the SSH host to use for a devenv workspace.
|
|
131
|
+
*
|
|
132
|
+
* - On-demand devenvs (devEnvWakeUrl + devEnvWakeToken set): wake the instance
|
|
133
|
+
* if it's not already running, poll the broker until it reports a public IP,
|
|
134
|
+
* probe TCP :22 until sshd accepts connections, then return the fresh IP.
|
|
135
|
+
* - Always-on devenvs (no wake URL): return the cached devEnvSshHostDns or
|
|
136
|
+
* devEnvSshHost from workspace env unchanged. No wake, no probe.
|
|
137
|
+
*
|
|
138
|
+
* The Listr UI ("Waking dev environment", "Waiting for SSH to accept
|
|
139
|
+
* connections") only renders on the on-demand path.
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveDevenvSshHost(env) {
|
|
142
|
+
const wakeUrl = env.devEnvWakeUrl;
|
|
143
|
+
const wakeToken = env.devEnvWakeToken;
|
|
144
|
+
if (wakeUrl && wakeToken) {
|
|
145
|
+
return wakeAndProbe(wakeUrl, wakeToken);
|
|
146
|
+
}
|
|
147
|
+
const cachedDns = env.devEnvSshHostDns;
|
|
148
|
+
const cachedHost = env.devEnvSshHost;
|
|
149
|
+
if (!cachedHost) {
|
|
150
|
+
throw new Error('devEnvSshHost must be set in the workspace environment');
|
|
151
|
+
}
|
|
152
|
+
return cachedDns || cachedHost;
|
|
153
|
+
}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
export declare const hereyaTokenUtils: {
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Issue a workspace/devenv-scoped Hereya token via POST /api/workspaces/:name.devenv-token.
|
|
4
|
+
*
|
|
5
|
+
* The resulting token is bound to the workspace's organisation and (optionally) carries
|
|
6
|
+
* another org member's identity via `onBehalfOfEmail`. Used by `hereya devenv install`.
|
|
7
|
+
* For non-workspace flows that just need a personal token, use `generatePersonalToken`.
|
|
8
|
+
*/
|
|
9
|
+
generateHereyaToken(opts: {
|
|
10
|
+
description: string;
|
|
11
|
+
onBehalfOfEmail?: string;
|
|
12
|
+
workspace: string;
|
|
13
|
+
}): Promise<null | {
|
|
14
|
+
cloudUrl: string;
|
|
15
|
+
ownerEmail: string;
|
|
16
|
+
token: string;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Issue a plain personal Hereya token via POST /api/personal-tokens.
|
|
20
|
+
*
|
|
21
|
+
* Returns the token under the caller's identity, with no organisation scope.
|
|
22
|
+
* Used by template-scaffolding flows (`hereya init`) where the token is consumed
|
|
23
|
+
* before any workspace exists, and the cross-org-reach concern that motivates
|
|
24
|
+
* `generateHereyaToken` doesn't apply.
|
|
25
|
+
*/
|
|
26
|
+
generatePersonalToken(description: string): Promise<null | {
|
|
3
27
|
cloudUrl: string;
|
|
4
28
|
token: string;
|
|
5
29
|
}>;
|
package/dist/lib/hereya-token.js
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
import { getCloudCredentials, loadBackendConfig } from '../backend/config.js';
|
|
2
2
|
export const hereyaTokenUtils = {
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Issue a workspace/devenv-scoped Hereya token via POST /api/workspaces/:name.devenv-token.
|
|
5
|
+
*
|
|
6
|
+
* The resulting token is bound to the workspace's organisation and (optionally) carries
|
|
7
|
+
* another org member's identity via `onBehalfOfEmail`. Used by `hereya devenv install`.
|
|
8
|
+
* For non-workspace flows that just need a personal token, use `generatePersonalToken`.
|
|
9
|
+
*/
|
|
10
|
+
async generateHereyaToken(opts) {
|
|
11
|
+
const backendConfig = await loadBackendConfig();
|
|
12
|
+
if (!backendConfig.cloud) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const { clientId, url } = backendConfig.cloud;
|
|
16
|
+
const credentials = await getCloudCredentials(clientId);
|
|
17
|
+
if (!credentials) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const body = {
|
|
21
|
+
description: opts.description,
|
|
22
|
+
expiresInDays: 365,
|
|
23
|
+
};
|
|
24
|
+
if (opts.onBehalfOfEmail) {
|
|
25
|
+
body.targetUserEmail = opts.onBehalfOfEmail;
|
|
26
|
+
}
|
|
27
|
+
const response = await fetch(`${url}/api/workspaces/${encodeURIComponent(opts.workspace)}.devenv-token`, {
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${credentials.accessToken}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
},
|
|
33
|
+
method: 'POST',
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const rawBody = await response.text();
|
|
37
|
+
let serverMessage;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(rawBody);
|
|
40
|
+
serverMessage = parsed.message ?? parsed.error;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Body wasn't JSON; fall through to use the raw text below.
|
|
44
|
+
}
|
|
45
|
+
const detail = serverMessage ?? rawBody.trim() ?? `HTTP ${response.status}`;
|
|
46
|
+
throw new Error(`Failed to generate Hereya token (HTTP ${response.status}): ${detail}`);
|
|
47
|
+
}
|
|
48
|
+
const result = (await response.json());
|
|
49
|
+
return { cloudUrl: url, ownerEmail: result.ownerEmail, token: result.token };
|
|
50
|
+
},
|
|
51
|
+
/**
|
|
52
|
+
* Issue a plain personal Hereya token via POST /api/personal-tokens.
|
|
53
|
+
*
|
|
54
|
+
* Returns the token under the caller's identity, with no organisation scope.
|
|
55
|
+
* Used by template-scaffolding flows (`hereya init`) where the token is consumed
|
|
56
|
+
* before any workspace exists, and the cross-org-reach concern that motivates
|
|
57
|
+
* `generateHereyaToken` doesn't apply.
|
|
58
|
+
*/
|
|
59
|
+
async generatePersonalToken(description) {
|
|
4
60
|
const backendConfig = await loadBackendConfig();
|
|
5
61
|
if (!backendConfig.cloud) {
|
|
6
62
|
return null;
|
|
@@ -23,7 +79,7 @@ export const hereyaTokenUtils = {
|
|
|
23
79
|
if (!response.ok) {
|
|
24
80
|
return null;
|
|
25
81
|
}
|
|
26
|
-
const result = await response.json();
|
|
82
|
+
const result = (await response.json());
|
|
27
83
|
return { cloudUrl: url, token: result.data.token };
|
|
28
84
|
},
|
|
29
85
|
};
|