hereya-cli 0.89.1 → 0.90.1

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.
Files changed (50) hide show
  1. package/README.md +65 -65
  2. package/dist/backend/cloud/cloud-backend/apps-deploy.d.ts +69 -0
  3. package/dist/backend/cloud/cloud-backend/apps-deploy.js +94 -0
  4. package/dist/backend/cloud/cloud-backend/apps-versions.d.ts +69 -0
  5. package/dist/backend/cloud/cloud-backend/apps-versions.js +113 -0
  6. package/dist/backend/cloud/cloud-backend/executor-jobs.d.ts +75 -0
  7. package/dist/backend/cloud/cloud-backend/executor-jobs.js +110 -0
  8. package/dist/backend/cloud/cloud-backend/misc.d.ts +5 -0
  9. package/dist/backend/cloud/cloud-backend/misc.js +78 -0
  10. package/dist/backend/cloud/cloud-backend/packages-publish.d.ts +3 -0
  11. package/dist/backend/cloud/cloud-backend/packages-publish.js +99 -0
  12. package/dist/backend/cloud/cloud-backend/packages-registry.d.ts +6 -0
  13. package/dist/backend/cloud/cloud-backend/packages-registry.js +146 -0
  14. package/dist/backend/cloud/cloud-backend/packages-workspace.d.ts +4 -0
  15. package/dist/backend/cloud/cloud-backend/packages-workspace.js +50 -0
  16. package/dist/backend/cloud/cloud-backend/projects.d.ts +7 -0
  17. package/dist/backend/cloud/cloud-backend/projects.js +122 -0
  18. package/dist/backend/cloud/cloud-backend/state.d.ts +6 -0
  19. package/dist/backend/cloud/cloud-backend/state.js +86 -0
  20. package/dist/backend/cloud/cloud-backend/utils.d.ts +55 -0
  21. package/dist/backend/cloud/cloud-backend/utils.js +56 -0
  22. package/dist/backend/cloud/cloud-backend/workspace-env.d.ts +5 -0
  23. package/dist/backend/cloud/cloud-backend/workspace-env.js +63 -0
  24. package/dist/backend/cloud/cloud-backend/workspaces.d.ts +7 -0
  25. package/dist/backend/cloud/cloud-backend/workspaces.js +124 -0
  26. package/dist/backend/cloud/cloud-backend.d.ts +56 -126
  27. package/dist/backend/cloud/cloud-backend.js +95 -1089
  28. package/dist/backend/cloud/cloud-backend.test.setup.d.ts +13 -0
  29. package/dist/backend/cloud/cloud-backend.test.setup.js +14 -0
  30. package/dist/backend/local.setup.d.ts +10 -0
  31. package/dist/backend/local.setup.js +20 -0
  32. package/dist/commands/executor/start/index.d.ts +1 -11
  33. package/dist/commands/executor/start/index.js +13 -498
  34. package/dist/lib/env/test.setup.d.ts +7 -0
  35. package/dist/lib/env/test.setup.js +18 -0
  36. package/dist/lib/executor-start/auth.d.ts +2 -0
  37. package/dist/lib/executor-start/auth.js +21 -0
  38. package/dist/lib/executor-start/execute-app-job.d.ts +13 -0
  39. package/dist/lib/executor-start/execute-app-job.js +146 -0
  40. package/dist/lib/executor-start/execute-deploy-job.d.ts +14 -0
  41. package/dist/lib/executor-start/execute-deploy-job.js +64 -0
  42. package/dist/lib/executor-start/execute-init-job.d.ts +14 -0
  43. package/dist/lib/executor-start/execute-init-job.js +135 -0
  44. package/dist/lib/executor-start/format.d.ts +13 -0
  45. package/dist/lib/executor-start/format.js +22 -0
  46. package/dist/lib/executor-start/job-dispatch.d.ts +15 -0
  47. package/dist/lib/executor-start/job-dispatch.js +89 -0
  48. package/dist/lib/package/index.d.ts +4 -4
  49. package/oclif.manifest.json +52 -52
  50. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ import nock from 'nock';
2
+ import { CloudBackend } from './cloud-backend.js';
3
+ export declare const cloudConfig: {
4
+ accessToken: string;
5
+ clientId: string;
6
+ refreshToken: string;
7
+ url: string;
8
+ };
9
+ export interface CloudBackendTestContext {
10
+ backend: CloudBackend;
11
+ scope: nock.Scope;
12
+ }
13
+ export declare function setupCloudBackendTest(): CloudBackendTestContext;
@@ -0,0 +1,14 @@
1
+ import nock from 'nock';
2
+ import { CloudBackend } from './cloud-backend.js';
3
+ export const cloudConfig = {
4
+ accessToken: 'test-token',
5
+ clientId: 'test-client-id',
6
+ refreshToken: 'test-refresh-token',
7
+ url: 'http://test.com',
8
+ };
9
+ export function setupCloudBackendTest() {
10
+ return {
11
+ backend: new CloudBackend(cloudConfig),
12
+ scope: nock(cloudConfig.url),
13
+ };
14
+ }
@@ -0,0 +1,10 @@
1
+ export interface LocalBackendTestContext {
2
+ homeDir: string;
3
+ }
4
+ /**
5
+ * Shared setup helpers for LocalFileBackend tests. Each split test file calls
6
+ * `setupLocalBackendTest()` from its `beforeEach` and uses the returned context
7
+ * to access the temp homeDir. Stubbing of `os.homedir` is restored by sinon.
8
+ */
9
+ export declare function setupLocalBackendTest(): Promise<LocalBackendTestContext>;
10
+ export declare function teardownLocalBackendTest(context: LocalBackendTestContext): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import * as sinon from 'sinon';
6
+ /**
7
+ * Shared setup helpers for LocalFileBackend tests. Each split test file calls
8
+ * `setupLocalBackendTest()` from its `beforeEach` and uses the returned context
9
+ * to access the temp homeDir. Stubbing of `os.homedir` is restored by sinon.
10
+ */
11
+ export async function setupLocalBackendTest() {
12
+ const homeDir = path.join(os.tmpdir(), 'hereya-test', randomUUID());
13
+ sinon.stub(os, 'homedir').returns(homeDir);
14
+ await fs.mkdir(path.join(homeDir, '.hereya', 'state', 'workspaces'), { recursive: true });
15
+ return { homeDir };
16
+ }
17
+ export async function teardownLocalBackendTest(context) {
18
+ sinon.restore();
19
+ await fs.rm(context.homeDir, { force: true, recursive: true });
20
+ }
@@ -1,11 +1,5 @@
1
1
  import { Command } from '@oclif/core';
2
- /**
3
- * Strip credentials from log strings before flushing them to the cloud.
4
- * Removes:
5
- * - any occurrence of the actual hereyaGitPassword value
6
- * - userinfo from URLs (https://user:pass@host -> https://host)
7
- */
8
- export declare function redactCredentials(str: string, password?: string): string;
2
+ export { redactCredentials } from '../../../lib/executor-start/format.js';
9
3
  export default class ExecutorStart extends Command {
10
4
  static description: string;
11
5
  static examples: string[];
@@ -15,9 +9,5 @@ export default class ExecutorStart extends Command {
15
9
  };
16
10
  run(): Promise<void>;
17
11
  private executeAppJob;
18
- private executeDeployJob;
19
12
  private executeInitJob;
20
- private executeJob;
21
- private markAppDeploymentFailed;
22
- private patchAppFinalState;
23
13
  }
@@ -1,59 +1,14 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import { randomUUID } from 'node:crypto';
3
- import fs from 'node:fs/promises';
4
3
  import os from 'node:os';
5
- import path from 'node:path';
6
4
  import { CloudBackend } from '../../../backend/cloud/cloud-backend.js';
7
- import { loginWithToken } from '../../../backend/cloud/login.js';
8
- import { saveCloudCredentials } from '../../../backend/config.js';
9
5
  import { clearBackend, getBackend } from '../../../backend/index.js';
10
6
  import { LocalExecutor } from '../../../executor/local.js';
11
- import * as appSourceLib from '../../../lib/app-source.js';
12
- import { SimpleConfigManager } from '../../../lib/config/simple.js';
13
- import { cloneWithCredentialHelper, gitUtils } from '../../../lib/git-utils.js';
14
- import { runShell, shellUtils } from '../../../lib/shell.js';
15
- /**
16
- * Workspaces look like `org/name`, which is not filesystem-safe. The
17
- * write-side in `app deploy --local` (commands/app/deploy/index.ts) MUST use
18
- * the same sanitization.
19
- */
20
- function sanitizeWorkspaceForFilename(workspace) {
21
- return workspace.replaceAll('/', '-');
22
- }
23
- /**
24
- * Strip credentials from log strings before flushing them to the cloud.
25
- * Removes:
26
- * - any occurrence of the actual hereyaGitPassword value
27
- * - userinfo from URLs (https://user:pass@host -> https://host)
28
- */
29
- export function redactCredentials(str, password) {
30
- let out = str;
31
- if (password) {
32
- // Replace every exact occurrence of the password with a placeholder.
33
- out = out.split(password).join('[REDACTED]');
34
- }
35
- // Strip userinfo from URLs (http/https/git).
36
- out = out.replaceAll(/(https?:\/\/|git:\/\/)([^/\s@]+@)/g, '$1');
37
- return out;
38
- }
39
- async function createAuthenticatedBackend(hereyaToken, cloudUrl) {
40
- const loginResult = await loginWithToken(cloudUrl, hereyaToken);
41
- if (!loginResult.success) {
42
- throw new Error(`Failed to authenticate: ${loginResult.error}`);
43
- }
44
- await saveCloudCredentials({
45
- accessToken: loginResult.accessToken,
46
- clientId: loginResult.clientId,
47
- refreshToken: loginResult.refreshToken,
48
- url: cloudUrl,
49
- });
50
- return new CloudBackend({
51
- accessToken: loginResult.accessToken,
52
- clientId: loginResult.clientId,
53
- refreshToken: loginResult.refreshToken,
54
- url: cloudUrl,
55
- });
56
- }
7
+ import { createAuthenticatedBackend } from '../../../lib/executor-start/auth.js';
8
+ import { executeAppJob } from '../../../lib/executor-start/execute-app-job.js';
9
+ import { executeInitJob } from '../../../lib/executor-start/execute-init-job.js';
10
+ import { dispatchJob } from '../../../lib/executor-start/job-dispatch.js';
11
+ export { redactCredentials } from '../../../lib/executor-start/format.js';
57
12
  export default class ExecutorStart extends Command {
58
13
  static description = `Start the remote executor process (polls for jobs from hereya cloud).
59
14
 
@@ -100,7 +55,6 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
100
55
  const executorId = `${os.hostname()}-${randomUUID().slice(0, 8)}`;
101
56
  this.log(`Starting executor ${executorId} for workspace: ${flags.workspace} (concurrency: ${concurrency})`);
102
57
  this.log('Polling for jobs...');
103
- // Handle graceful shutdown
104
58
  const state = { shuttingDown: false };
105
59
  const shutdown = () => {
106
60
  this.log('Shutting down executor...');
@@ -109,9 +63,9 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
109
63
  process.on('SIGINT', shutdown);
110
64
  process.on('SIGTERM', shutdown);
111
65
  const activeJobs = new Set();
66
+ const hooks = { log: (m) => this.log(m), warn: (m) => this.warn(m) };
112
67
  while (!state.shuttingDown) {
113
68
  try {
114
- // Wait for a slot to open up if at concurrency limit
115
69
  if (activeJobs.size >= concurrency) {
116
70
  // eslint-disable-next-line no-await-in-loop
117
71
  await Promise.race(activeJobs);
@@ -138,474 +92,35 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
138
92
  continue;
139
93
  }
140
94
  if (!pollResult.job) {
141
- // No job available, continue polling
142
95
  continue;
143
96
  }
144
97
  const { job } = pollResult;
145
98
  this.log(`Received job ${job.id} (${job.type}) [${activeJobs.size + 1}/${concurrency} slots used]`);
146
- // Fire off the job without awaiting it
147
- const jobPromise = this.executeJob(job, executor, cloudBackend).finally(() => {
99
+ const jobPromise = dispatchJob(job, executor, cloudBackend, hooks).finally(() => {
148
100
  activeJobs.delete(jobPromise);
149
101
  });
150
102
  activeJobs.add(jobPromise);
151
103
  }
152
104
  catch (error) {
153
105
  this.warn(`Executor error: ${error.message}`);
154
- // Wait before retrying
155
106
  // eslint-disable-next-line no-await-in-loop
156
107
  await new Promise(resolve => {
157
108
  setTimeout(resolve, 5000);
158
109
  });
159
110
  }
160
111
  }
161
- // Wait for all active jobs to complete before exiting
162
112
  if (activeJobs.size > 0) {
163
113
  this.log(`Waiting for ${activeJobs.size} active job(s) to complete...`);
164
114
  await Promise.allSettled(activeJobs);
165
115
  }
166
116
  this.log('Executor stopped.');
167
117
  }
168
- async executeAppJob(job, cloudBackend, logger) {
169
- const isDeploy = job.type === 'app-deploy';
170
- const subcommand = isDeploy ? 'deploy' : 'destroy';
171
- const payload = job.payload;
172
- const { appName, commit, parameters: providedParameters, repository, sha256, version, workspace } = payload;
173
- if (!appName || !workspace || !repository) {
174
- return {
175
- reason: `Missing appName, workspace, or repository in ${job.type} job payload`,
176
- success: false,
177
- };
178
- }
179
- let cleanup;
180
- try {
181
- logger.info(`Downloading app source ${repository}${commit ? `@${commit}` : ''}...`);
182
- const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
183
- cleanup = downloadResult.cleanup;
184
- const { rootDir } = downloadResult;
185
- // Mark deployment as deploying / destroying.
186
- await cloudBackend.updateAppDeployment({
187
- lastJobId: job.id,
188
- name: appName,
189
- status: isDeploy ? 'deploying' : 'destroying',
190
- workspace,
191
- });
192
- // Build -p flag args from job payload parameters.
193
- const parameters = providedParameters ?? {};
194
- const paramFlags = [];
195
- for (const [key, value] of Object.entries(parameters)) {
196
- paramFlags.push('-p', `${key}=${value}`);
197
- }
198
- // For app-deploy, forward --version so the local command can serialize it into
199
- // the deploy-env file's `state` block (which the cloud reads back via PATCH).
200
- const versionFlags = isDeploy && version ? ['--version', version] : [];
201
- // Shell out to `hereya app <subcommand> <appName> -w <workspace> [-p ...] [--version ...] --local`.
202
- // The local command holds the provisioning logic (mirrors deploy/undeploy shell-out pattern).
203
- logger.info(`Running hereya app ${subcommand} ${appName} -w ${workspace} --local...`);
204
- let runResult;
205
- try {
206
- runResult = await shellUtils.runShell('hereya', ['app', subcommand, appName, '-w', workspace, ...paramFlags, ...versionFlags, '--local'], {
207
- directory: rootDir,
208
- env: { ...process.env, HEREYA_LOCAL_EXECUTION: 'true', HEREYA_PROJECT_ROOT_DIR: rootDir },
209
- logger,
210
- });
211
- }
212
- catch (error) {
213
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: error.message, workspace }, cloudBackend);
214
- return { reason: `hereya app ${subcommand} failed: ${error.message}`, success: false };
215
- }
216
- if (runResult && runResult.status !== 0) {
217
- const reason = `hereya app ${subcommand} exited with code ${runResult.status}`;
218
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason, workspace }, cloudBackend);
219
- return { reason, success: false };
220
- }
221
- // PATCH final state to the cloud.
222
- const patchResult = await this.patchAppFinalState({
223
- appName,
224
- cloudBackend,
225
- isDeploy,
226
- jobId: job.id,
227
- rootDir,
228
- workspace,
229
- });
230
- if (!patchResult.success) {
231
- return patchResult;
232
- }
233
- return { success: true };
234
- }
235
- catch (error) {
236
- try {
237
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: error.message, workspace }, cloudBackend);
238
- }
239
- catch {
240
- // best-effort
241
- }
242
- return { reason: `app-${subcommand} failed: ${error.message}`, success: false };
243
- }
244
- finally {
245
- if (cleanup) {
246
- await cleanup();
247
- }
248
- }
249
- }
250
- async executeDeployJob(job, executor, cloudBackend, logger) {
251
- const { gitBranch, project, workspace } = job.payload;
252
- if (!project || !workspace) {
253
- return { reason: 'Missing project or workspace in job payload', success: false };
254
- }
255
- // 1. Get project metadata to find git credentials
256
- const metadata$ = await cloudBackend.getProjectMetadata({ project });
257
- if (!metadata$.found) {
258
- return { reason: `No metadata found for project ${project}`, success: false };
259
- }
260
- const { metadata } = metadata$;
261
- if (!metadata.env?.hereyaGitRemoteUrl) {
262
- return { reason: 'Project metadata missing hereyaGitRemoteUrl', success: false };
263
- }
264
- // 2. Resolve git credentials using the local executor
265
- const gitEnvToResolve = {};
266
- for (const [key, value] of Object.entries(metadata.env)) {
267
- if (key.startsWith('hereyaGit'))
268
- gitEnvToResolve[key] = value;
269
- }
270
- const resolvedGitEnv = await executor.resolveEnvValues({ env: gitEnvToResolve, project, workspace });
271
- // 3. Clone repo at specified branch into temp dir
272
- const tempDir = path.join(os.tmpdir(), `hereya-deploy-${randomUUID()}`);
273
- try {
274
- const branch = gitBranch || 'main';
275
- logger.info(`Cloning ${resolvedGitEnv.hereyaGitRemoteUrl} (branch: ${branch})...`);
276
- const cloneResult = await cloneWithCredentialHelper({
277
- gitUrl: resolvedGitEnv.hereyaGitRemoteUrl,
278
- hereyaBinPath: process.argv[1],
279
- password: resolvedGitEnv.hereyaGitPassword,
280
- targetDir: tempDir,
281
- username: resolvedGitEnv.hereyaGitUsername,
282
- });
283
- if (!cloneResult.success) {
284
- return { reason: `Git clone failed: ${cloneResult.reason}`, success: false };
285
- }
286
- // Checkout the correct branch if not main/master
287
- if (branch !== 'main' && branch !== 'master') {
288
- await runShell('git', ['checkout', branch], { directory: tempDir, logger });
289
- }
290
- // 4. Run hereya deploy/undeploy --local inside the cloned repo
291
- const command = job.type === 'deploy' ? 'deploy' : 'undeploy';
292
- logger.info(`Running hereya ${command} -w ${workspace} --local...`);
293
- const result = await runShell('hereya', [command, '-w', workspace, '--local'], {
294
- directory: tempDir,
295
- env: { ...process.env, HEREYA_LOCAL_EXECUTION: 'true', HEREYA_PROJECT_ROOT_DIR: tempDir },
296
- logger,
297
- });
298
- logger.info(`hereya ${command} exited with code ${result.status}`);
299
- if (result.status !== 0) {
300
- return { reason: `hereya ${command} failed (exit code ${result.status})`, success: false };
301
- }
302
- return { success: true };
303
- }
304
- finally {
305
- // 5. Clean up temp dir
306
- try {
307
- await fs.rm(tempDir, { force: true, recursive: true });
308
- }
309
- catch {
310
- // Ignore cleanup errors
311
- }
312
- }
313
- }
314
- async executeInitJob(job, executor, logger) {
315
- const payload = job.payload;
316
- const { deployWorkspace, parameters, projectName, template, workspace } = payload;
317
- if (!projectName || !workspace || !template || !deployWorkspace) {
318
- return {
319
- reason: 'Missing projectName, workspace, template, or deployWorkspace in job payload',
320
- success: false,
321
- };
322
- }
323
- // Track the password here (captured after provision) so the redacting logger knows what
324
- // to strip. Until we have it, redaction still runs for URL userinfo.
325
- let password;
326
- const redactingLogger = {
327
- debug(msg) {
328
- logger.debug(redactCredentials(msg, password));
329
- },
330
- error(msg) {
331
- logger.error(redactCredentials(msg, password));
332
- },
333
- info(msg) {
334
- logger.info(redactCredentials(msg, password));
335
- },
336
- };
337
- const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hereya-init-'));
338
- try {
339
- // 1. Provision template
340
- redactingLogger.info(`Provisioning template ${template}...`);
341
- const provisionResult = await executor.provision({
342
- logger: redactingLogger,
343
- package: template,
344
- parameters: {
345
- ...parameters,
346
- deployWorkspace,
347
- projectName,
348
- workspace,
349
- },
350
- project: projectName,
351
- workspace,
352
- });
353
- if (!provisionResult.success) {
354
- return { reason: `Template provisioning failed: ${provisionResult.reason}`, success: false };
355
- }
356
- const { env } = provisionResult;
357
- const { hereyaGitPassword, hereyaGitRemoteUrl, hereyaGitUsername } = env;
358
- password = hereyaGitPassword;
359
- if (!hereyaGitRemoteUrl) {
360
- return { reason: 'Template did not export hereyaGitRemoteUrl', success: false };
361
- }
362
- // 2. Clone repo into scratch dir.
363
- redactingLogger.info(`Cloning ${hereyaGitRemoteUrl}...`);
364
- // Clone into a subdirectory so we control the target exactly.
365
- const repoDir = path.join(scratchDir, 'repo');
366
- const cloneResult = await gitUtils.cloneWithCredentialHelper({
367
- gitUrl: hereyaGitRemoteUrl,
368
- hereyaBinPath: process.argv[1],
369
- password: hereyaGitPassword,
370
- targetDir: repoDir,
371
- username: hereyaGitUsername,
372
- });
373
- if (!cloneResult.success) {
374
- return { reason: `Git clone failed: ${cloneResult.reason}`, success: false };
375
- }
376
- // 3. Write hereya.yaml
377
- redactingLogger.info('Writing hereya.yaml...');
378
- const config = {
379
- project: projectName,
380
- workspace,
381
- };
382
- await new SimpleConfigManager().saveConfig({ config, projectRootDir: repoDir });
383
- // 4. Commit + push
384
- redactingLogger.info('Committing and pushing initial project config...');
385
- const gitEnv = {
386
- ...process.env,
387
- GIT_AUTHOR_EMAIL: 'executor@hereya.dev',
388
- GIT_AUTHOR_NAME: 'hereya-executor',
389
- GIT_COMMITTER_EMAIL: 'executor@hereya.dev',
390
- GIT_COMMITTER_NAME: 'hereya-executor',
391
- };
392
- if (hereyaGitUsername)
393
- gitEnv.hereyaGitUsername = hereyaGitUsername;
394
- if (hereyaGitPassword)
395
- gitEnv.hereyaGitPassword = hereyaGitPassword;
396
- try {
397
- await shellUtils.runShell('git', ['add', '.'], { directory: repoDir, env: gitEnv, logger: redactingLogger });
398
- await shellUtils.runShell('git', ['commit', '-m', '"chore: hereya init"'], {
399
- directory: repoDir,
400
- env: gitEnv,
401
- logger: redactingLogger,
402
- });
403
- await shellUtils.runShell('git', ['push', 'origin', 'HEAD'], {
404
- directory: repoDir,
405
- env: gitEnv,
406
- logger: redactingLogger,
407
- });
408
- }
409
- catch (error) {
410
- return {
411
- reason: `Git commit/push failed: ${redactCredentials(error.message ?? String(error), password)}`,
412
- success: false,
413
- };
414
- }
415
- return { hereyaGitRemoteUrl, success: true };
416
- }
417
- catch (error) {
418
- return {
419
- reason: `Init failed: ${redactCredentials(error.message ?? String(error), password)}`,
420
- success: false,
421
- };
422
- }
423
- finally {
424
- try {
425
- await fs.rm(scratchDir, { force: true, recursive: true });
426
- }
427
- catch {
428
- // Ignore cleanup errors
429
- }
430
- }
431
- }
432
- async executeJob(job, executor, cloudBackend) {
433
- // Set up buffered logging
434
- let logBuffer = '';
435
- const logInterval = setInterval(async () => {
436
- if (logBuffer) {
437
- const logs = logBuffer;
438
- logBuffer = '';
439
- try {
440
- await cloudBackend.updateExecutorJob({
441
- jobId: job.id,
442
- logs,
443
- });
444
- }
445
- catch {
446
- // Log flush errors are non-fatal
447
- }
448
- }
449
- }, 10_000);
450
- // Heartbeat every 30 seconds (independent of log flushing)
451
- const heartbeatInterval = setInterval(async () => {
452
- try {
453
- await cloudBackend.updateExecutorJob({ jobId: job.id });
454
- }
455
- catch {
456
- // Heartbeat errors are non-fatal
457
- }
458
- }, 30_000);
459
- const logger = {
460
- debug(msg) {
461
- logBuffer += msg + '\n';
462
- },
463
- error(msg) {
464
- logBuffer += msg + '\n';
465
- },
466
- info(msg) {
467
- logBuffer += msg + '\n';
468
- },
469
- };
470
- try {
471
- if (job.type === 'resolve-env') {
472
- // resolve-env job: resolve env values using local executor
473
- const resolved = await executor.resolveEnvValues(job.payload);
474
- // Send resolved env as result
475
- clearInterval(logInterval);
476
- clearInterval(heartbeatInterval);
477
- await cloudBackend.updateExecutorJob({
478
- jobId: job.id,
479
- logs: logBuffer,
480
- result: { env: resolved, success: true },
481
- status: 'completed',
482
- });
483
- this.log(`Job ${job.id} completed (resolve-env)`);
484
- return;
485
- }
486
- if (job.type === 'deploy' || job.type === 'undeploy') {
487
- const deployResult = await this.executeDeployJob(job, executor, cloudBackend, logger);
488
- clearInterval(logInterval);
489
- clearInterval(heartbeatInterval);
490
- await cloudBackend.updateExecutorJob({
491
- jobId: job.id,
492
- logs: logBuffer,
493
- result: deployResult,
494
- status: deployResult.success ? 'completed' : 'failed',
495
- });
496
- this.log(`Job ${job.id} ${deployResult.success ? 'completed' : 'failed'} (${job.type})`);
497
- return;
498
- }
499
- if (job.type === 'init') {
500
- const initResult = await this.executeInitJob(job, executor, logger);
501
- clearInterval(logInterval);
502
- clearInterval(heartbeatInterval);
503
- await cloudBackend.updateExecutorJob({
504
- jobId: job.id,
505
- logs: logBuffer,
506
- result: initResult,
507
- status: initResult.success ? 'completed' : 'failed',
508
- });
509
- this.log(`Job ${job.id} ${initResult.success ? 'completed' : 'failed'} (init)`);
510
- return;
511
- }
512
- if (job.type === 'app-deploy' || job.type === 'app-destroy') {
513
- const appJobResult = await this.executeAppJob(job, cloudBackend, logger);
514
- clearInterval(logInterval);
515
- clearInterval(heartbeatInterval);
516
- await cloudBackend.updateExecutorJob({
517
- jobId: job.id,
518
- logs: logBuffer,
519
- result: appJobResult,
520
- status: appJobResult.success ? 'completed' : 'failed',
521
- });
522
- this.log(`Job ${job.id} ${appJobResult.success ? 'completed' : 'failed'} (${job.type})`);
523
- return;
524
- }
525
- const result = job.type === 'provision'
526
- ? await executor.provision({
527
- ...job.payload,
528
- logger,
529
- })
530
- : await executor.destroy({
531
- ...job.payload,
532
- logger,
533
- });
534
- // Flush remaining logs and send result
535
- clearInterval(logInterval);
536
- clearInterval(heartbeatInterval);
537
- await cloudBackend.updateExecutorJob({
538
- jobId: job.id,
539
- logs: logBuffer,
540
- result,
541
- status: result.success ? 'completed' : 'failed',
542
- });
543
- this.log(`Job ${job.id} ${result.success ? 'completed' : 'failed'}`);
544
- }
545
- catch (error) {
546
- clearInterval(logInterval);
547
- clearInterval(heartbeatInterval);
548
- await cloudBackend.updateExecutorJob({
549
- jobId: job.id,
550
- logs: logBuffer + `\nError: ${error.message}\n`,
551
- result: { reason: error.message, success: false },
552
- status: 'failed',
553
- });
554
- this.warn(`Job ${job.id} failed: ${error.message}`);
555
- }
556
- }
557
- async markAppDeploymentFailed(input, cloudBackend) {
558
- try {
559
- await cloudBackend.updateAppDeployment({
560
- lastJobId: input.jobId,
561
- name: input.appName,
562
- status: 'failed',
563
- workspace: input.workspace,
564
- });
565
- }
566
- catch {
567
- // best-effort; failure of the PATCH must not bubble out
568
- }
118
+ // Test-friendly delegators kept on the class for backwards compatibility with
119
+ // tests that call `(cmd as any).executeInitJob(...)` / `executeAppJob(...)`.
120
+ executeAppJob(job, cloudBackend, logger) {
121
+ return executeAppJob(job, cloudBackend, logger);
569
122
  }
570
- async patchAppFinalState(input) {
571
- const { appName, cloudBackend, isDeploy, jobId, rootDir, workspace } = input;
572
- if (!isDeploy) {
573
- const updateResult = await cloudBackend.updateAppDeployment({
574
- env: {},
575
- lastJobId: jobId,
576
- name: appName,
577
- status: 'destroyed',
578
- workspace,
579
- });
580
- if (!updateResult.success) {
581
- return { reason: `Destroy succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
582
- }
583
- return { success: true };
584
- }
585
- // Read .hereya/deploy-env.<workspace>.json that was written by `app deploy --local`.
586
- // Filename sanitization MUST match the write side (see `sanitizeWorkspaceForFilename`).
587
- const deployEnvFile = path.join(rootDir, '.hereya', `deploy-env.${sanitizeWorkspaceForFilename(workspace)}.json`);
588
- let parsed;
589
- try {
590
- const content = await fs.readFile(deployEnvFile, 'utf8');
591
- parsed = JSON.parse(content);
592
- }
593
- catch (error) {
594
- const reason = `Failed to read deploy env file at ${deployEnvFile}: ${error.message}`;
595
- await this.markAppDeploymentFailed({ appName, jobId, reason, workspace }, cloudBackend);
596
- return { reason, success: false };
597
- }
598
- const updateResult = await cloudBackend.updateAppDeployment({
599
- env: parsed.env ?? {},
600
- lastJobId: jobId,
601
- name: appName,
602
- state: parsed.state,
603
- status: 'deployed',
604
- workspace,
605
- });
606
- if (!updateResult.success) {
607
- return { reason: `Provisioning succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
608
- }
609
- return { success: true };
123
+ executeInitJob(job, executor, logger) {
124
+ return executeInitJob(job, executor, logger);
610
125
  }
611
126
  }
@@ -0,0 +1,7 @@
1
+ import { EnvManager } from './index.js';
2
+ export interface EnvManagerTestContext {
3
+ envManager: EnvManager;
4
+ tempDir: string;
5
+ }
6
+ export declare function setupEnvManagerTest(): Promise<EnvManagerTestContext>;
7
+ export declare function teardownEnvManagerTest(context: EnvManagerTestContext): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import * as sinon from 'sinon';
6
+ import { EnvManager } from './index.js';
7
+ export async function setupEnvManagerTest() {
8
+ const tempDir = path.join(os.tmpdir(), 'hereya-test-env', randomUUID());
9
+ await fs.mkdir(path.join(tempDir, '.hereya'), { recursive: true });
10
+ process.env.HEREYA_PROJECT_ROOT_DIR = tempDir;
11
+ const envManager = new EnvManager();
12
+ return { envManager, tempDir };
13
+ }
14
+ export async function teardownEnvManagerTest(context) {
15
+ sinon.restore();
16
+ await fs.rm(context.tempDir, { force: true, recursive: true });
17
+ delete process.env.HEREYA_PROJECT_ROOT_DIR;
18
+ }