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.
@@ -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
- const sshHostDns = env.devEnvSshHostDns;
76
- if (!sshHost || !sshPrivateKey || !sshUser) {
77
- this.error('devEnvSshHost, devEnvSshPrivateKey, and devEnvSshUser must be set in the workspace environment');
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
- const sshHostDns = env.devEnvSshHostDns;
54
- if (!sshHost || !sshPrivateKey || !sshUser) {
55
- this.error('devEnvSshHost, devEnvSshPrivateKey, and devEnvSshUser must be set in the workspace environment');
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);
@@ -7,7 +7,4 @@ export default class DevenvSsh extends Command {
7
7
  };
8
8
  run(): Promise<void>;
9
9
  protected spawnSsh(args: string[]): Promise<void>;
10
- private fetchStatus;
11
- private postWake;
12
- private wakeAndPoll;
13
10
  }
@@ -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
- const sshHostDns = env.devEnvSshHostDns;
103
- const wakeUrl = env.devEnvWakeUrl;
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
- if (wakeUrl && wakeToken) {
109
- try {
110
- const wakeResult = await this.wakeAndPoll(wakeUrl, wakeToken);
111
- // Override sshHost with the freshly-resolved IP from the broker
112
- sshHost = wakeResult.host;
113
- }
114
- catch (error) {
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.generateHereyaToken(`Template: ${args.project}`);
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
- generateHereyaToken(description: string): Promise<null | {
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
  }>;
@@ -1,6 +1,62 @@
1
1
  import { getCloudCredentials, loadBackendConfig } from '../backend/config.js';
2
2
  export const hereyaTokenUtils = {
3
- async generateHereyaToken(description) {
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
  };