hereya-cli 0.85.3 → 0.85.5

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.
@@ -3,78 +3,22 @@ import { randomUUID } from 'node:crypto';
3
3
  import fs from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import * as yaml from 'yaml';
7
6
  import { CloudBackend } from '../../../backend/cloud/cloud-backend.js';
8
7
  import { loginWithToken } from '../../../backend/cloud/login.js';
9
8
  import { saveCloudCredentials } from '../../../backend/config.js';
10
9
  import { clearBackend, getBackend } from '../../../backend/index.js';
11
10
  import { LocalExecutor } from '../../../executor/local.js';
12
- import { destroyPackage, provisionPackage } from '../../../infrastructure/index.js';
13
- import { resolveAppParameters, substituteAppParameters } from '../../../lib/app-parameters.js';
14
11
  import * as appSourceLib from '../../../lib/app-source.js';
15
12
  import { SimpleConfigManager } from '../../../lib/config/simple.js';
16
13
  import { cloneWithCredentialHelper, gitUtils } from '../../../lib/git-utils.js';
17
14
  import { runShell, shellUtils } from '../../../lib/shell.js';
18
15
  /**
19
- * Indirection for testability: tests can stub `appPackageOps.provisionPackage`
20
- * and `appPackageOps.destroyPackage` to assert call shape without going through
21
- * the real infrastructure layer.
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.
22
19
  */
23
- export const appPackageOps = {
24
- destroyPackage,
25
- provisionPackage,
26
- };
27
- async function getAnyExisting(...candidates) {
28
- for (const candidate of candidates) {
29
- try {
30
- // eslint-disable-next-line no-await-in-loop
31
- await fs.access(candidate);
32
- return candidate;
33
- }
34
- catch {
35
- // try next
36
- }
37
- }
38
- return undefined;
39
- }
40
- function normalizePackageList(raw) {
41
- if (!raw)
42
- return [];
43
- if (Array.isArray(raw)) {
44
- return raw
45
- .filter((entry) => typeof entry === 'string' && entry.length > 0)
46
- .map((entry) => {
47
- const [name, version] = entry.split('@');
48
- return {
49
- name,
50
- spec: entry,
51
- version,
52
- };
53
- });
54
- }
55
- if (typeof raw === 'object') {
56
- return Object.entries(raw).map(([name, info]) => {
57
- const version = info?.version;
58
- return {
59
- name,
60
- spec: version ? `${name}@${version}` : name,
61
- version,
62
- };
63
- });
64
- }
65
- return [];
66
- }
67
- async function runPreDeployCommand(input) {
68
- if (!input.command)
69
- return { success: true };
70
- try {
71
- input.logger.info('Running pre-deploy command...');
72
- await runShell(input.command, [], { directory: input.directory, logger: input.logger });
73
- return { success: true };
74
- }
75
- catch (error) {
76
- return { reason: `pre-deploy command failed: ${error.message}`, success: false };
77
- }
20
+ function sanitizeWorkspaceForFilename(workspace) {
21
+ return workspace.replaceAll('/', '-');
78
22
  }
79
23
  /**
80
24
  * Strip credentials from log strings before flushing them to the cloud.
@@ -221,12 +165,14 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
221
165
  }
222
166
  this.log('Executor stopped.');
223
167
  }
224
- async executeAppDeployJob(job, cloudBackend, logger) {
168
+ async executeAppJob(job, cloudBackend, logger) {
169
+ const isDeploy = job.type === 'app-deploy';
170
+ const subcommand = isDeploy ? 'deploy' : 'destroy';
225
171
  const payload = job.payload;
226
172
  const { appName, commit, parameters: providedParameters, repository, sha256, version, workspace } = payload;
227
173
  if (!appName || !workspace || !repository) {
228
174
  return {
229
- reason: 'Missing appName, workspace, or repository in app-deploy job payload',
175
+ reason: `Missing appName, workspace, or repository in ${job.type} job payload`,
230
176
  success: false,
231
177
  };
232
178
  }
@@ -236,110 +182,53 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
236
182
  const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
237
183
  cleanup = downloadResult.cleanup;
238
184
  const { rootDir } = downloadResult;
239
- // Re-read declarations + substitute hereyavars before any package work.
240
- const paramsResult = await this.resolveAndSubstituteParams({
241
- logger,
242
- provided: providedParameters,
243
- rootDir,
244
- });
245
- if (!paramsResult.success) {
246
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: paramsResult.reason, workspace }, cloudBackend);
247
- return { reason: paramsResult.reason, success: false };
248
- }
249
- const { parameters: resolvedParameters } = paramsResult;
250
- // Read hereya.yaml from app source
251
- const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
252
- if (!hereyaYamlPath) {
253
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: 'Missing hereya.yaml', workspace }, cloudBackend);
254
- return { reason: 'App source does not contain a hereya.yaml file', success: false };
255
- }
256
- const hereyaYamlContent = await fs.readFile(hereyaYamlPath, 'utf8');
257
- const parsedHereyaYaml = (yaml.parse(hereyaYamlContent) ?? {});
258
- // Build package lists
259
- const packages = normalizePackageList(parsedHereyaYaml.packages);
260
- const deployPackages = normalizePackageList(parsedHereyaYaml.deployPackages ?? parsedHereyaYaml.deploy);
261
- // Resolve workspace env (used as projectEnv for deploy-marked packages)
262
- const workspaceEnvResult = await cloudBackend.getWorkspaceEnv({ workspace });
263
- const workspaceEnv = workspaceEnvResult.success ? workspaceEnvResult.env : {};
264
- // Mark deployment as deploying
185
+ // Mark deployment as deploying / destroying.
265
186
  await cloudBackend.updateAppDeployment({
266
187
  lastJobId: job.id,
267
188
  name: appName,
268
- status: 'deploying',
189
+ status: isDeploy ? 'deploying' : 'destroying',
269
190
  workspace,
270
191
  });
271
- const preDeployResult = await runPreDeployCommand({
272
- command: parsedHereyaYaml.preDeployCommand,
273
- directory: rootDir,
274
- logger,
275
- });
276
- if (!preDeployResult.success) {
277
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: preDeployResult.reason, workspace }, cloudBackend);
278
- return { reason: preDeployResult.reason, success: false };
279
- }
280
- // mergedEnv chains outputs between packages so later packages can read
281
- // earlier ones. deployEnv accumulates ONLY the outputs from deploy
282
- // packages that's the AppDeployment's public surface, what gets
283
- // displayed in the cloud UI and returned by `hereya app env`.
284
- const mergedEnv = {};
285
- const deployEnv = {};
286
- // 1) Provision regular packages
287
- // Workspace env is merged in so packages like aws-postgres-serverless that
288
- // depend on workspace-level outputs (clusterArn, masterSecretArn, ...) can
289
- // resolve them. Mirrors the env-flow that `hereya add` produces in the
290
- // project lifecycle.
291
- /* eslint-disable no-await-in-loop */
292
- for (const pkg of packages) {
293
- const result = await appPackageOps.provisionPackage({
294
- env: { ...workspaceEnv, ...mergedEnv },
295
- isDeploying: true,
296
- logger,
297
- package: pkg.spec,
298
- parameters: resolvedParameters,
299
- projectRootDir: rootDir,
300
- workspace,
301
- });
302
- if (!result.success) {
303
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: result.reason, workspace }, cloudBackend);
304
- return { reason: `Failed to provision package ${pkg.name}: ${result.reason}`, success: false };
305
- }
306
- Object.assign(mergedEnv, result.env);
307
- }
308
- // 2) Provision deploy packages — these get workspace env merged in
309
- for (const pkg of deployPackages) {
310
- const result = await appPackageOps.provisionPackage({
311
- env: { ...workspaceEnv, ...mergedEnv },
312
- isDeploying: true,
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 },
313
209
  logger,
314
- package: pkg.spec,
315
- parameters: resolvedParameters,
316
- projectEnv: { ...workspaceEnv, ...mergedEnv },
317
- projectRootDir: rootDir,
318
- workspace,
319
210
  });
320
- if (!result.success) {
321
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: result.reason, workspace }, cloudBackend);
322
- return { reason: `Failed to provision deploy package ${pkg.name}: ${result.reason}`, success: false };
323
- }
324
- Object.assign(mergedEnv, result.env);
325
- Object.assign(deployEnv, result.env);
326
211
  }
327
- /* eslint-enable no-await-in-loop */
328
- // 3) PATCH AppDeployment with final state only deploy package outputs
329
- const updateResult = await cloudBackend.updateAppDeployment({
330
- env: deployEnv,
331
- lastJobId: job.id,
332
- name: appName,
333
- state: {
334
- deployPackages: deployPackages.map((p) => ({ name: p.name, version: p.version })),
335
- packages: packages.map((p) => ({ name: p.name, version: p.version })),
336
- version,
337
- },
338
- status: 'deployed',
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,
339
228
  workspace,
340
229
  });
341
- if (!updateResult.success) {
342
- return { reason: `Provisioning succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
230
+ if (!patchResult.success) {
231
+ return patchResult;
343
232
  }
344
233
  return { success: true };
345
234
  }
@@ -350,114 +239,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
350
239
  catch {
351
240
  // best-effort
352
241
  }
353
- return { reason: `app-deploy failed: ${error.message}`, success: false };
354
- }
355
- finally {
356
- if (cleanup) {
357
- await cleanup();
358
- }
359
- }
360
- }
361
- async executeAppDestroyJob(job, cloudBackend, logger) {
362
- const payload = job.payload;
363
- const { appName, commit, parameters: providedParameters, repository, sha256, workspace } = payload;
364
- if (!appName || !workspace || !repository) {
365
- return {
366
- reason: 'Missing appName, workspace, or repository in app-destroy job payload',
367
- success: false,
368
- };
369
- }
370
- let cleanup;
371
- try {
372
- logger.info(`Downloading app source for destroy: ${repository}${commit ? `@${commit}` : ''}...`);
373
- const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
374
- cleanup = downloadResult.cleanup;
375
- const { rootDir } = downloadResult;
376
- // Re-read declarations + substitute hereyavars before any package work.
377
- const paramsResult = await this.resolveAndSubstituteParams({
378
- logger,
379
- provided: providedParameters,
380
- rootDir,
381
- });
382
- if (!paramsResult.success) {
383
- await cloudBackend.updateAppDeployment({
384
- lastJobId: job.id,
385
- name: appName,
386
- status: 'failed',
387
- workspace,
388
- });
389
- return { reason: paramsResult.reason, success: false };
390
- }
391
- const { parameters: resolvedParameters } = paramsResult;
392
- const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
393
- if (!hereyaYamlPath) {
394
- return { reason: 'App source does not contain a hereya.yaml file', success: false };
395
- }
396
- const parsedHereyaYaml = (yaml.parse(await fs.readFile(hereyaYamlPath, 'utf8')) ?? {});
397
- const packages = normalizePackageList(parsedHereyaYaml.packages);
398
- const deployPackages = normalizePackageList(parsedHereyaYaml.deployPackages ?? parsedHereyaYaml.deploy);
399
- // Mark deployment as destroying
400
- await cloudBackend.updateAppDeployment({
401
- lastJobId: job.id,
402
- name: appName,
403
- status: 'destroying',
404
- workspace,
405
- });
406
- const preDeployResult = await runPreDeployCommand({
407
- command: parsedHereyaYaml.preDeployCommand,
408
- directory: rootDir,
409
- logger,
410
- });
411
- if (!preDeployResult.success) {
412
- await cloudBackend.updateAppDeployment({
413
- lastJobId: job.id,
414
- name: appName,
415
- status: 'failed',
416
- workspace,
417
- });
418
- return { reason: preDeployResult.reason, success: false };
419
- }
420
- // Iterate in REVERSE: deploy packages first (depend on regular pkgs), then regular pkgs.
421
- const reversed = [...deployPackages.reverse(), ...packages.reverse()];
422
- const workspaceEnvResult = await cloudBackend.getWorkspaceEnv({ workspace });
423
- const workspaceEnv = workspaceEnvResult.success ? workspaceEnvResult.env : {};
424
- /* eslint-disable no-await-in-loop */
425
- for (const pkg of reversed) {
426
- const result = await appPackageOps.destroyPackage({
427
- env: workspaceEnv,
428
- isDeploying: true,
429
- logger,
430
- package: pkg.spec,
431
- parameters: resolvedParameters,
432
- projectEnv: workspaceEnv,
433
- projectRootDir: rootDir,
434
- workspace,
435
- });
436
- if (!result.success) {
437
- await cloudBackend.updateAppDeployment({
438
- lastJobId: job.id,
439
- name: appName,
440
- status: 'failed',
441
- workspace,
442
- });
443
- return { reason: `Failed to destroy package ${pkg.name}: ${result.reason}`, success: false };
444
- }
445
- }
446
- /* eslint-enable no-await-in-loop */
447
- const updateResult = await cloudBackend.updateAppDeployment({
448
- env: {},
449
- lastJobId: job.id,
450
- name: appName,
451
- status: 'destroyed',
452
- workspace,
453
- });
454
- if (!updateResult.success) {
455
- return { reason: `Destroy succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
456
- }
457
- return { success: true };
458
- }
459
- catch (error) {
460
- return { reason: `app-destroy failed: ${error.message}`, success: false };
242
+ return { reason: `app-${subcommand} failed: ${error.message}`, success: false };
461
243
  }
462
244
  finally {
463
245
  if (cleanup) {
@@ -727,30 +509,17 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
727
509
  this.log(`Job ${job.id} ${initResult.success ? 'completed' : 'failed'} (init)`);
728
510
  return;
729
511
  }
730
- if (job.type === 'app-deploy') {
731
- const appDeployResult = await this.executeAppDeployJob(job, cloudBackend, logger);
512
+ if (job.type === 'app-deploy' || job.type === 'app-destroy') {
513
+ const appJobResult = await this.executeAppJob(job, cloudBackend, logger);
732
514
  clearInterval(logInterval);
733
515
  clearInterval(heartbeatInterval);
734
516
  await cloudBackend.updateExecutorJob({
735
517
  jobId: job.id,
736
518
  logs: logBuffer,
737
- result: appDeployResult,
738
- status: appDeployResult.success ? 'completed' : 'failed',
519
+ result: appJobResult,
520
+ status: appJobResult.success ? 'completed' : 'failed',
739
521
  });
740
- this.log(`Job ${job.id} ${appDeployResult.success ? 'completed' : 'failed'} (app-deploy)`);
741
- return;
742
- }
743
- if (job.type === 'app-destroy') {
744
- const appDestroyResult = await this.executeAppDestroyJob(job, cloudBackend, logger);
745
- clearInterval(logInterval);
746
- clearInterval(heartbeatInterval);
747
- await cloudBackend.updateExecutorJob({
748
- jobId: job.id,
749
- logs: logBuffer,
750
- result: appDestroyResult,
751
- status: appDestroyResult.success ? 'completed' : 'failed',
752
- });
753
- this.log(`Job ${job.id} ${appDestroyResult.success ? 'completed' : 'failed'} (app-destroy)`);
522
+ this.log(`Job ${job.id} ${appJobResult.success ? 'completed' : 'failed'} (${job.type})`);
754
523
  return;
755
524
  }
756
525
  const result = job.type === 'provision'
@@ -798,34 +567,45 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
798
567
  // best-effort; failure of the PATCH must not bubble out
799
568
  }
800
569
  }
801
- async readDeclaredParameters(rootDir) {
802
- const hereyarcPath = await getAnyExisting(path.join(rootDir, 'hereyarc.yaml'), path.join(rootDir, 'hereyarc.yml'));
803
- if (!hereyarcPath) {
804
- return {};
805
- }
806
- const content = await fs.readFile(hereyarcPath, 'utf8');
807
- const parsed = (yaml.parse(content) ?? {});
808
- return parsed.parameters ?? {};
809
- }
810
- async resolveAndSubstituteParams(input) {
811
- const declared = await this.readDeclaredParameters(input.rootDir);
812
- const resolved = resolveAppParameters({ declared, provided: input.provided });
813
- if (resolved.errors.length > 0) {
814
- return { reason: `Parameter validation failed: ${resolved.errors.join('; ')}`, success: false };
815
- }
816
- try {
817
- const { filesWritten } = await substituteAppParameters({
818
- declaredNames: Object.keys(declared),
819
- parameters: resolved.parameters,
820
- rootDir: input.rootDir,
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,
821
579
  });
822
- if (filesWritten.length > 0) {
823
- input.logger.info(`Substituted parameters in ${filesWritten.length} hereyavars file(s).`);
580
+ if (!updateResult.success) {
581
+ return { reason: `Destroy succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
824
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);
825
592
  }
826
593
  catch (error) {
827
- return { reason: `Failed to substitute hereyavars parameters: ${error.message}`, success: false };
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 };
828
608
  }
829
- return { parameters: resolved.parameters, success: true };
609
+ return { success: true };
830
610
  }
831
611
  }
@@ -2,6 +2,7 @@ import { InfrastructureType } from '../infrastructure/common.js';
2
2
  import { Logger } from '../lib/log.js';
3
3
  import { IPackageMetadata } from '../lib/package/index.js';
4
4
  export type ExecutorProvisionInput = {
5
+ app?: string;
5
6
  isDeploying?: boolean;
6
7
  logger?: Logger;
7
8
  package: string;
@@ -4,8 +4,10 @@ import { mintInstallationToken } from '../lib/github-app.js';
4
4
  import { resolvePackage } from '../lib/package/index.js';
5
5
  export class LocalExecutor {
6
6
  async destroy(input) {
7
+ // For apps, we use `app` as the project-name source for workspace env resolution
8
+ // (workspace name normalization just needs the org-prefixed scope name).
7
9
  const getWorkspaceEnvOutput = await this.getWorkspaceEnv({
8
- project: input.project,
10
+ project: input.app ?? input.project,
9
11
  skipResolve: true,
10
12
  workspace: input.workspace,
11
13
  });
@@ -51,8 +53,10 @@ export class LocalExecutor {
51
53
  return { metadata, pkgName, success: true, version };
52
54
  }
53
55
  async provision(input) {
56
+ // For apps, we use `app` as the project-name source for workspace env resolution
57
+ // (workspace name normalization just needs the org-prefixed scope name).
54
58
  const getWorkspaceEnvOutput = await this.getWorkspaceEnv({
55
- project: input.project,
59
+ project: input.app ?? input.project,
56
60
  skipResolve: true,
57
61
  workspace: input.workspace,
58
62
  });
@@ -10,7 +10,8 @@ export declare function resolveEnvValues(env: Record<string, string>): Promise<R
10
10
  export declare function getInfrastructure(input: GetInfrastructureInput): GetInfrastructureOutput;
11
11
  export declare function destroyPackage(input: DestroyPackageInput): Promise<DestroyPackageOutput>;
12
12
  export declare function provisionPackage(input: ProvisionPackageInput): Promise<ProvisionPackageOutput>;
13
- export declare function getProvisioningLogicalId({ pkg, project, workspace }: {
13
+ export declare function getProvisioningLogicalId({ app, pkg, project, workspace }: {
14
+ app?: string;
14
15
  pkg: string;
15
16
  project?: string;
16
17
  workspace?: string;
@@ -18,6 +19,7 @@ export declare function getProvisioningLogicalId({ pkg, project, workspace }: {
18
19
  export type DestroyPackageInput = ProvisionPackageInput;
19
20
  export type DestroyPackageOutput = ProvisionPackageOutput;
20
21
  export type ProvisionPackageInput = {
22
+ app?: string;
21
23
  env?: {
22
24
  [key: string]: string;
23
25
  };
@@ -43,6 +43,20 @@ export function getInfrastructure(input) {
43
43
  }
44
44
  }
45
45
  }
46
+ /**
47
+ * When `input.app` is set, scope the provisioning row by `app` (workspace-scoped).
48
+ * Otherwise, scope by `project`. Apps and projects are mutually exclusive scopes
49
+ * for the cloud's provisioning-id row.
50
+ */
51
+ function buildProvisioningIdInput({ canonicalName, input, pkgName }) {
52
+ return {
53
+ app: input.app,
54
+ logicalId: getProvisioningLogicalId({ app: input.app, pkg: pkgName, project: input.project, workspace: input.workspace }),
55
+ packageCanonicalName: canonicalName,
56
+ project: input.app ? undefined : input.project,
57
+ workspace: input.workspace,
58
+ };
59
+ }
46
60
  export async function destroyPackage(input) {
47
61
  const resolvePackageOutput = await resolvePackage({ isDeploying: input.isDeploying, logger: input.logger, package: input.package, projectRootDir: input.projectRootDir });
48
62
  if (!resolvePackageOutput.found) {
@@ -62,12 +76,7 @@ export async function destroyPackage(input) {
62
76
  }
63
77
  const { infrastructure } = infrastructure$;
64
78
  const backend = await getBackend();
65
- const id$ = await backend.getProvisioningId({
66
- logicalId: getProvisioningLogicalId({ pkg: pkgName, project: input.project, workspace: input.workspace }),
67
- packageCanonicalName: canonicalName,
68
- project: input.project,
69
- workspace: input.workspace,
70
- });
79
+ const id$ = await backend.getProvisioningId(buildProvisioningIdInput({ canonicalName, input, pkgName }));
71
80
  if (!id$.success) {
72
81
  return { reason: id$.reason, success: false };
73
82
  }
@@ -214,12 +223,7 @@ export async function provisionPackage(input) {
214
223
  }
215
224
  const { infrastructure } = infrastructure$;
216
225
  const backend = await getBackend();
217
- const id$ = await backend.getProvisioningId({
218
- logicalId: getProvisioningLogicalId({ pkg: pkgName, project: input.project, workspace: input.workspace }),
219
- packageCanonicalName: canonicalName,
220
- project: input.project,
221
- workspace: input.workspace,
222
- });
226
+ const id$ = await backend.getProvisioningId(buildProvisioningIdInput({ canonicalName, input, pkgName }));
223
227
  if (!id$.success) {
224
228
  return { reason: id$.reason, success: false };
225
229
  }
@@ -233,8 +237,11 @@ export async function provisionPackage(input) {
233
237
  }
234
238
  return { env: provisionOutput.env, metadata, pkgName, success: true, version: version || '' };
235
239
  }
236
- export function getProvisioningLogicalId({ pkg, project, workspace }) {
237
- const projectName = project ? stripOrgPrefix(project) : project;
240
+ export function getProvisioningLogicalId({ app, pkg, project, workspace }) {
241
+ // Apps and projects are mutually exclusive scopes for the logical id. When `app` is set,
242
+ // the logical id is keyed by the app name (workspace-scoped, not project-scoped).
243
+ const scope = app ?? project;
244
+ const scopeName = scope ? stripOrgPrefix(scope) : scope;
238
245
  const workspaceName = workspace ? stripOrgPrefix(workspace) : workspace;
239
- return [pkg.replaceAll('/', '.'), workspaceName, projectName].filter(Boolean).join('.');
246
+ return [pkg.replaceAll('/', '.'), workspaceName, scopeName].filter(Boolean).join('.');
240
247
  }
@@ -1,3 +1,4 @@
1
+ import { Logger } from './log.js';
1
2
  import { IParameterSpec } from './package/index.js';
2
3
  export interface ResolveAppParametersInput {
3
4
  declared?: Record<string, IParameterSpec>;
@@ -39,3 +40,25 @@ export interface SubstituteAppParametersOutput {
39
40
  * If the hereyavars directory doesn't exist, this is a no-op.
40
41
  */
41
42
  export declare function substituteAppParameters(input: SubstituteAppParametersInput): Promise<SubstituteAppParametersOutput>;
43
+ export interface ResolveAndSubstituteAppParamsInput {
44
+ logger?: Logger;
45
+ provided?: Record<string, string>;
46
+ rootDir: string;
47
+ }
48
+ export type ResolveAndSubstituteAppParamsOutput = {
49
+ parameters: Record<string, string>;
50
+ success: true;
51
+ } | {
52
+ reason: string;
53
+ success: false;
54
+ };
55
+ /**
56
+ * Read declared parameters from `<rootDir>/hereyarc.yaml`, validate provided
57
+ * values against the declarations, then substitute `{{name}}` placeholders in
58
+ * any `hereyaconfig/hereyavars/*.yaml` files. Returns the resolved parameter
59
+ * map for the caller to forward to `executor.provision({parameters})`.
60
+ *
61
+ * Used by `hereya app deploy --local`, `hereya app destroy --local`, and the
62
+ * remote executor wrapper (which now shells out to those commands).
63
+ */
64
+ export declare function resolveAndSubstituteAppParams(input: ResolveAndSubstituteAppParamsInput): Promise<ResolveAndSubstituteAppParamsOutput>;