hereya-cli 0.84.1 → 0.85.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.
@@ -109,8 +109,6 @@ export class CloudBackend {
109
109
  body.version = input.version;
110
110
  if (input.parameters)
111
111
  body.parameters = input.parameters;
112
- if (input.hereyaVarsYaml !== undefined)
113
- body.hereyaVarsYaml = input.hereyaVarsYaml;
114
112
  const response = await fetch(`${this.config.url}/api/apps/${encodeURIComponent(input.name)}/deployments`, {
115
113
  body: JSON.stringify(body),
116
114
  headers: {
@@ -207,6 +205,27 @@ export class CloudBackend {
207
205
  const result = await response.json();
208
206
  return { deployment: result.deployment, success: true };
209
207
  }
208
+ async getAppVersion(input) {
209
+ const url = input.version
210
+ ? `${this.config.url}/api/apps/${encodeURIComponent(input.name)}/versions/${encodeURIComponent(input.version)}`
211
+ : `${this.config.url}/api/apps/${encodeURIComponent(input.name)}/versions/latest`;
212
+ const response = await fetch(url, {
213
+ headers: { 'Authorization': `Bearer ${this.config.accessToken}` },
214
+ method: 'GET',
215
+ });
216
+ if (!response.ok) {
217
+ const error = await this.safeResponseJson(response);
218
+ let errorMessage = error.error || `Failed to get app version (status ${response.status})`;
219
+ if (response.status === 404) {
220
+ errorMessage = input.version
221
+ ? `App version '${input.name}@${input.version}' not found`
222
+ : `App '${input.name}' not found`;
223
+ }
224
+ return { reason: errorMessage, success: false };
225
+ }
226
+ const result = await response.json();
227
+ return { appVersion: result.appVersion ?? result.version, success: true };
228
+ }
210
229
  async getExecutorJobStatus(input) {
211
230
  const url = new URL(`${this.config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/jobs/${encodeURIComponent(input.jobId)}`);
212
231
  if (input.poll) {
@@ -621,6 +640,8 @@ export class CloudBackend {
621
640
  requestBody.description = input.description;
622
641
  if (input.visibility !== undefined)
623
642
  requestBody.visibility = input.visibility;
643
+ if (input.parameters !== undefined)
644
+ requestBody.parameters = input.parameters;
624
645
  const response = await fetch(`${this.config.url}/api/apps/${encodeURIComponent(input.name)}/versions`, {
625
646
  body: JSON.stringify(requestBody),
626
647
  headers: {
@@ -7,8 +7,6 @@ export default class AppDeploy extends Command {
7
7
  static examples: string[];
8
8
  static flags: {
9
9
  parameter: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
10
- vars: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
- 'vars-file': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
10
  version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
11
  workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
12
  };
@@ -1,8 +1,8 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import { ListrLogger, ListrLogLevels } from 'listr2';
3
- import fs from 'node:fs/promises';
4
3
  import { CloudBackend } from '../../../backend/cloud/cloud-backend.js';
5
4
  import { getBackend } from '../../../backend/index.js';
5
+ import { resolveAppParameters } from '../../../lib/app-parameters.js';
6
6
  import { arrayOfStringToObject } from '../../../lib/object-utils.js';
7
7
  import { pollExecutorJob } from '../../../lib/remote-job-utils.js';
8
8
  export default class AppDeploy extends Command {
@@ -13,8 +13,7 @@ export default class AppDeploy extends Command {
13
13
  static examples = [
14
14
  '<%= config.bin %> <%= command.id %> my-org/my-app -w my-workspace',
15
15
  '<%= config.bin %> <%= command.id %> my-org/my-app -w prod --version 1.2.0',
16
- '<%= config.bin %> <%= command.id %> my-org/my-app -w prod -V \'app.yaml: "key: value"\'',
17
- '<%= config.bin %> <%= command.id %> my-org/my-app -w prod --vars-file ./hereyavars.yaml',
16
+ '<%= config.bin %> <%= command.id %> my-org/my-app -w prod -p organizationId=org-123',
18
17
  ];
19
18
  static flags = {
20
19
  parameter: Flags.string({
@@ -23,15 +22,6 @@ export default class AppDeploy extends Command {
23
22
  description: 'parameter for the app deployment, in the form of key=value (repeatable)',
24
23
  multiple: true,
25
24
  }),
26
- vars: Flags.string({
27
- char: 'V',
28
- description: 'YAML string mapping hereyavars filename -> YAML body (mutually exclusive with --vars-file)',
29
- exclusive: ['vars-file'],
30
- }),
31
- 'vars-file': Flags.string({
32
- description: 'path to a YAML file mapping hereyavars filename -> YAML body (mutually exclusive with --vars)',
33
- exclusive: ['vars'],
34
- }),
35
25
  version: Flags.string({
36
26
  description: 'specific app version to deploy (defaults to latest)',
37
27
  }),
@@ -49,27 +39,23 @@ export default class AppDeploy extends Command {
49
39
  }
50
40
  const cloudBackend = backend;
51
41
  const myLogger = new ListrLogger({ useIcons: false });
52
- // Parse parameters
53
- const parameters = arrayOfStringToObject(flags.parameter);
54
- // Resolve hereyaVarsYaml
55
- let hereyaVarsYaml;
56
- if (flags.vars && flags['vars-file']) {
57
- this.error('Cannot use both --vars and --vars-file at the same time.');
58
- }
59
- else if (flags.vars) {
60
- hereyaVarsYaml = flags.vars;
42
+ // Parse user-provided parameters
43
+ const provided = arrayOfStringToObject(flags.parameter);
44
+ // Fetch the target version's declared parameters before submitting any cloud job.
45
+ const versionResult = await cloudBackend.getAppVersion({
46
+ name: args.name,
47
+ version: flags.version,
48
+ });
49
+ if (!versionResult.success) {
50
+ this.error(`Failed to resolve app version: ${versionResult.reason}`);
61
51
  }
62
- else if (flags['vars-file']) {
63
- try {
64
- hereyaVarsYaml = await fs.readFile(flags['vars-file'], 'utf8');
65
- }
66
- catch (error) {
67
- this.error(`Failed to read vars file ${flags['vars-file']}: ${error.message}`);
68
- }
52
+ const declared = versionResult.appVersion.parameters ?? {};
53
+ const { errors, parameters } = resolveAppParameters({ declared, provided });
54
+ if (errors.length > 0) {
55
+ this.error(['Cannot deploy app due to parameter errors:', ...errors.map((e) => ` - ${e}`)].join('\n'));
69
56
  }
70
57
  myLogger.log(ListrLogLevels.STARTED, `Deploying ${args.name}${flags.version ? `@${flags.version}` : ''} to workspace ${flags.workspace}...`);
71
58
  const deployResult = await cloudBackend.deployApp({
72
- hereyaVarsYaml,
73
59
  name: args.name,
74
60
  parameters: Object.keys(parameters).length > 0 ? parameters : undefined,
75
61
  version: flags.version,
@@ -30,4 +30,6 @@ export default class ExecutorStart extends Command {
30
30
  private executeInitJob;
31
31
  private executeJob;
32
32
  private markAppDeploymentFailed;
33
+ private readDeclaredParameters;
34
+ private resolveAndSubstituteParams;
33
35
  }
@@ -10,8 +10,8 @@ import { saveCloudCredentials } from '../../../backend/config.js';
10
10
  import { clearBackend, getBackend } from '../../../backend/index.js';
11
11
  import { LocalExecutor } from '../../../executor/local.js';
12
12
  import { destroyPackage, provisionPackage } from '../../../infrastructure/index.js';
13
+ import { resolveAppParameters, substituteAppParameters } from '../../../lib/app-parameters.js';
13
14
  import * as appSourceLib from '../../../lib/app-source.js';
14
- import { applyHereyaVars } from '../../../lib/app-vars.js';
15
15
  import { SimpleConfigManager } from '../../../lib/config/simple.js';
16
16
  import { cloneWithCredentialHelper, gitUtils } from '../../../lib/git-utils.js';
17
17
  import { runShell, shellUtils } from '../../../lib/shell.js';
@@ -223,7 +223,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
223
223
  }
224
224
  async executeAppDeployJob(job, cloudBackend, logger) {
225
225
  const payload = job.payload;
226
- const { appName, commit, hereyaVarsYaml, parameters, repository, sha256, version, workspace } = payload;
226
+ const { appName, commit, parameters: providedParameters, repository, sha256, version, workspace } = payload;
227
227
  if (!appName || !workspace || !repository) {
228
228
  return {
229
229
  reason: 'Missing appName, workspace, or repository in app-deploy job payload',
@@ -236,17 +236,17 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
236
236
  const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
237
237
  cleanup = downloadResult.cleanup;
238
238
  const { rootDir } = downloadResult;
239
- // Apply hereyavars overrides
240
- try {
241
- const { filesWritten } = await applyHereyaVars(rootDir, hereyaVarsYaml);
242
- if (filesWritten.length > 0) {
243
- logger.info(`Wrote ${filesWritten.length} hereyavars override file(s).`);
244
- }
245
- }
246
- catch (error) {
247
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: error.message, workspace }, cloudBackend);
248
- return { reason: `Failed to apply hereyavars overrides: ${error.message}`, success: false };
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 };
249
248
  }
249
+ const { parameters: resolvedParameters } = paramsResult;
250
250
  // Read hereya.yaml from app source
251
251
  const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
252
252
  if (!hereyaYamlPath) {
@@ -279,13 +279,18 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
279
279
  }
280
280
  const mergedEnv = {};
281
281
  // 1) Provision regular packages
282
+ // Workspace env is merged in so packages like aws-postgres-serverless that
283
+ // depend on workspace-level outputs (clusterArn, masterSecretArn, ...) can
284
+ // resolve them. Mirrors the env-flow that `hereya add` produces in the
285
+ // project lifecycle.
282
286
  /* eslint-disable no-await-in-loop */
283
287
  for (const pkg of packages) {
284
288
  const result = await appPackageOps.provisionPackage({
289
+ env: { ...workspaceEnv, ...mergedEnv },
285
290
  isDeploying: true,
286
291
  logger,
287
292
  package: pkg.spec,
288
- parameters,
293
+ parameters: resolvedParameters,
289
294
  projectRootDir: rootDir,
290
295
  workspace,
291
296
  });
@@ -298,10 +303,11 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
298
303
  // 2) Provision deploy packages — these get workspace env merged in
299
304
  for (const pkg of deployPackages) {
300
305
  const result = await appPackageOps.provisionPackage({
306
+ env: { ...workspaceEnv, ...mergedEnv },
301
307
  isDeploying: true,
302
308
  logger,
303
309
  package: pkg.spec,
304
- parameters,
310
+ parameters: resolvedParameters,
305
311
  projectEnv: { ...workspaceEnv, ...mergedEnv },
306
312
  projectRootDir: rootDir,
307
313
  workspace,
@@ -348,7 +354,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
348
354
  }
349
355
  async executeAppDestroyJob(job, cloudBackend, logger) {
350
356
  const payload = job.payload;
351
- const { appName, commit, hereyaVarsYaml, parameters, repository, sha256, workspace } = payload;
357
+ const { appName, commit, parameters: providedParameters, repository, sha256, workspace } = payload;
352
358
  if (!appName || !workspace || !repository) {
353
359
  return {
354
360
  reason: 'Missing appName, workspace, or repository in app-destroy job payload',
@@ -361,13 +367,22 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
361
367
  const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
362
368
  cleanup = downloadResult.cleanup;
363
369
  const { rootDir } = downloadResult;
364
- // Apply hereyavars overrides destroy needs the same vars to drive Terraform.
365
- try {
366
- await applyHereyaVars(rootDir, hereyaVarsYaml);
367
- }
368
- catch (error) {
369
- return { reason: `Failed to apply hereyavars overrides: ${error.message}`, success: false };
370
+ // Re-read declarations + substitute hereyavars before any package work.
371
+ const paramsResult = await this.resolveAndSubstituteParams({
372
+ logger,
373
+ provided: providedParameters,
374
+ rootDir,
375
+ });
376
+ if (!paramsResult.success) {
377
+ await cloudBackend.updateAppDeployment({
378
+ lastJobId: job.id,
379
+ name: appName,
380
+ status: 'failed',
381
+ workspace,
382
+ });
383
+ return { reason: paramsResult.reason, success: false };
370
384
  }
385
+ const { parameters: resolvedParameters } = paramsResult;
371
386
  const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
372
387
  if (!hereyaYamlPath) {
373
388
  return { reason: 'App source does not contain a hereya.yaml file', success: false };
@@ -403,10 +418,11 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
403
418
  /* eslint-disable no-await-in-loop */
404
419
  for (const pkg of reversed) {
405
420
  const result = await appPackageOps.destroyPackage({
421
+ env: workspaceEnv,
406
422
  isDeploying: true,
407
423
  logger,
408
424
  package: pkg.spec,
409
- parameters,
425
+ parameters: resolvedParameters,
410
426
  projectEnv: workspaceEnv,
411
427
  projectRootDir: rootDir,
412
428
  workspace,
@@ -776,4 +792,34 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
776
792
  // best-effort; failure of the PATCH must not bubble out
777
793
  }
778
794
  }
795
+ async readDeclaredParameters(rootDir) {
796
+ const hereyarcPath = await getAnyExisting(path.join(rootDir, 'hereyarc.yaml'), path.join(rootDir, 'hereyarc.yml'));
797
+ if (!hereyarcPath) {
798
+ return {};
799
+ }
800
+ const content = await fs.readFile(hereyarcPath, 'utf8');
801
+ const parsed = (yaml.parse(content) ?? {});
802
+ return parsed.parameters ?? {};
803
+ }
804
+ async resolveAndSubstituteParams(input) {
805
+ const declared = await this.readDeclaredParameters(input.rootDir);
806
+ const resolved = resolveAppParameters({ declared, provided: input.provided });
807
+ if (resolved.errors.length > 0) {
808
+ return { reason: `Parameter validation failed: ${resolved.errors.join('; ')}`, success: false };
809
+ }
810
+ try {
811
+ const { filesWritten } = await substituteAppParameters({
812
+ declaredNames: Object.keys(declared),
813
+ parameters: resolved.parameters,
814
+ rootDir: input.rootDir,
815
+ });
816
+ if (filesWritten.length > 0) {
817
+ input.logger.info(`Substituted parameters in ${filesWritten.length} hereyavars file(s).`);
818
+ }
819
+ }
820
+ catch (error) {
821
+ return { reason: `Failed to substitute hereyavars parameters: ${error.message}`, success: false };
822
+ }
823
+ return { parameters: resolved.parameters, success: true };
824
+ }
779
825
  }
@@ -1,4 +1,5 @@
1
1
  import { Command } from '@oclif/core';
2
+ import { IParameterSpec } from '../../lib/package/index.js';
2
3
  interface HereyarcConfig {
3
4
  description?: string;
4
5
  iac?: string;
@@ -9,6 +10,7 @@ interface HereyarcConfig {
9
10
  pkg: string;
10
11
  version: string;
11
12
  };
13
+ parameters?: Record<string, unknown>;
12
14
  version: string;
13
15
  visibility?: 'PRIVATE' | 'private' | 'PUBLIC' | 'public';
14
16
  }
@@ -28,6 +30,7 @@ export default class Publish extends Command {
28
30
  loadConfig(packageDir: string): Promise<HereyarcConfig>;
29
31
  loadReadme(packageDir: string): string | undefined;
30
32
  run(): Promise<void>;
33
+ validateAppParameters(rawParameters: unknown): Record<string, IParameterSpec>;
31
34
  validateConfig(config: HereyarcConfig): void;
32
35
  private publishApp;
33
36
  }
@@ -5,6 +5,7 @@ import { simpleGit } from 'simple-git';
5
5
  import * as yaml from 'yaml';
6
6
  import { CloudBackend } from '../../backend/cloud/cloud-backend.js';
7
7
  import { getBackend } from '../../backend/index.js';
8
+ import { PARAMETER_NAME_PATTERN, ParameterSpec } from '../../lib/package/index.js';
8
9
  export default class Publish extends Command {
9
10
  static description = 'Publish a package to the Hereya registry';
10
11
  static examples = [
@@ -225,6 +226,24 @@ export default class Publish extends Command {
225
226
  this.error(error instanceof Error ? error.message : String(error));
226
227
  }
227
228
  }
229
+ validateAppParameters(rawParameters) {
230
+ if (rawParameters === null || typeof rawParameters !== 'object' || Array.isArray(rawParameters)) {
231
+ this.error('hereyarc.yaml "parameters" must be a mapping of name -> spec');
232
+ }
233
+ const result = {};
234
+ for (const [name, rawSpec] of Object.entries(rawParameters)) {
235
+ if (!PARAMETER_NAME_PATTERN.test(name)) {
236
+ this.error(`Invalid parameter name '${name}' in hereyarc.yaml. Names must match ${PARAMETER_NAME_PATTERN.source}`);
237
+ }
238
+ const parsed = ParameterSpec.safeParse(rawSpec);
239
+ if (!parsed.success) {
240
+ const issues = parsed.error.issues.map((i) => i.message).join('; ');
241
+ this.error(`Invalid parameter '${name}' in hereyarc.yaml: ${issues}`);
242
+ }
243
+ result[name] = parsed.data;
244
+ }
245
+ return result;
246
+ }
228
247
  validateConfig(config) {
229
248
  if (!config.name) {
230
249
  this.error('Missing required field "name" in hereyarc.yaml');
@@ -291,11 +310,17 @@ export default class Publish extends Command {
291
310
  }
292
311
  this.log(' ✓ Authenticated with Hereya Cloud');
293
312
  this.log('\n📤 Publishing app version to registry...');
313
+ // Validate the parameters block (if any) against the zod schema before sending.
314
+ let validatedParameters;
315
+ if (config.parameters !== undefined) {
316
+ validatedParameters = this.validateAppParameters(config.parameters);
317
+ }
294
318
  const result = await backend.publishAppVersion({
295
319
  commit: commit.trim(),
296
320
  description: config.description,
297
321
  hereyaYaml: hereyaYamlContent,
298
322
  name: config.name,
323
+ ...(validatedParameters === undefined ? {} : { parameters: validatedParameters }),
299
324
  repository,
300
325
  sha256,
301
326
  version: config.version,
@@ -0,0 +1,41 @@
1
+ import { IParameterSpec } from './package/index.js';
2
+ export interface ResolveAppParametersInput {
3
+ declared?: Record<string, IParameterSpec>;
4
+ provided?: Record<string, string>;
5
+ }
6
+ export interface ResolveAppParametersOutput {
7
+ errors: string[];
8
+ parameters: Record<string, string>;
9
+ }
10
+ /**
11
+ * Build the resolved parameter map for an app deployment by:
12
+ * 1. Starting with each declared parameter's `default` (if present).
13
+ * 2. Overlaying user-provided values (`-p key=value`).
14
+ *
15
+ * Validates:
16
+ * - No unknown keys in `provided` (must match declared names).
17
+ * - Every mandatory declared parameter ends up with a value.
18
+ *
19
+ * Multiple errors are aggregated; a non-empty `errors` array means the caller
20
+ * MUST refuse to submit the deploy.
21
+ */
22
+ export declare function resolveAppParameters(input: ResolveAppParametersInput): ResolveAppParametersOutput;
23
+ export interface SubstituteAppParametersInput {
24
+ declaredNames: string[];
25
+ parameters: Record<string, string>;
26
+ rootDir: string;
27
+ }
28
+ export interface SubstituteAppParametersOutput {
29
+ filesWritten: string[];
30
+ }
31
+ /**
32
+ * Walk `<rootDir>/hereyaconfig/hereyavars/` recursively. For every `*.yaml`
33
+ * or `*.yml` file, replace `{{name}}` for each (name, value) pair. After the
34
+ * pass, error if any leftover `{{<declaredName>}}` is found in the file.
35
+ *
36
+ * Replacement order: longest parameter name first, to avoid prefix collisions
37
+ * (e.g., `{{org}}` should not match part of `{{organization}}`).
38
+ *
39
+ * If the hereyavars directory doesn't exist, this is a no-op.
40
+ */
41
+ export declare function substituteAppParameters(input: SubstituteAppParametersInput): Promise<SubstituteAppParametersOutput>;
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Build the resolved parameter map for an app deployment by:
5
+ * 1. Starting with each declared parameter's `default` (if present).
6
+ * 2. Overlaying user-provided values (`-p key=value`).
7
+ *
8
+ * Validates:
9
+ * - No unknown keys in `provided` (must match declared names).
10
+ * - Every mandatory declared parameter ends up with a value.
11
+ *
12
+ * Multiple errors are aggregated; a non-empty `errors` array means the caller
13
+ * MUST refuse to submit the deploy.
14
+ */
15
+ export function resolveAppParameters(input) {
16
+ const declared = input.declared ?? {};
17
+ const provided = input.provided ?? {};
18
+ const errors = [];
19
+ // Reject unknown keys
20
+ const declaredNames = new Set(Object.keys(declared));
21
+ const unknownKeys = [];
22
+ for (const key of Object.keys(provided)) {
23
+ if (!declaredNames.has(key)) {
24
+ unknownKeys.push(key);
25
+ }
26
+ }
27
+ if (unknownKeys.length > 0) {
28
+ errors.push(`Unknown parameter(s): ${unknownKeys.sort().join(', ')}. Declared parameters: ${[...declaredNames].sort().join(', ') || '(none)'}`);
29
+ }
30
+ // Build resolved map
31
+ const parameters = {};
32
+ // 1) Apply declared defaults
33
+ for (const [name, spec] of Object.entries(declared)) {
34
+ if (spec.default !== undefined) {
35
+ parameters[name] = spec.default;
36
+ }
37
+ }
38
+ // 2) Overlay provided values (only the declared ones — unknown keys reported above)
39
+ for (const [name, value] of Object.entries(provided)) {
40
+ if (declaredNames.has(name)) {
41
+ parameters[name] = value;
42
+ }
43
+ }
44
+ // Validate mandatory
45
+ const missingMandatory = [];
46
+ for (const [name, spec] of Object.entries(declared)) {
47
+ if (spec.mandatory && parameters[name] === undefined) {
48
+ missingMandatory.push(name);
49
+ }
50
+ }
51
+ if (missingMandatory.length > 0) {
52
+ errors.push(`Missing required parameter(s): ${missingMandatory
53
+ .sort()
54
+ .map((n) => `-p ${n}=<value>`)
55
+ .join(', ')}`);
56
+ }
57
+ return { errors, parameters };
58
+ }
59
+ /**
60
+ * Walk `<rootDir>/hereyaconfig/hereyavars/` recursively. For every `*.yaml`
61
+ * or `*.yml` file, replace `{{name}}` for each (name, value) pair. After the
62
+ * pass, error if any leftover `{{<declaredName>}}` is found in the file.
63
+ *
64
+ * Replacement order: longest parameter name first, to avoid prefix collisions
65
+ * (e.g., `{{org}}` should not match part of `{{organization}}`).
66
+ *
67
+ * If the hereyavars directory doesn't exist, this is a no-op.
68
+ */
69
+ export async function substituteAppParameters(input) {
70
+ const { declaredNames, parameters, rootDir } = input;
71
+ const targetDir = path.join(rootDir, 'hereyaconfig', 'hereyavars');
72
+ let exists = true;
73
+ try {
74
+ await fs.access(targetDir);
75
+ }
76
+ catch {
77
+ exists = false;
78
+ }
79
+ if (!exists) {
80
+ return { filesWritten: [] };
81
+ }
82
+ // Sort substitution order: longest names first.
83
+ const sortedEntries = Object.entries(parameters).sort(([aName], [bName]) => bName.length - aName.length);
84
+ const filesWritten = [];
85
+ const yamlFiles = await collectYamlFiles(targetDir);
86
+ for (const filePath of yamlFiles) {
87
+ // eslint-disable-next-line no-await-in-loop
88
+ let content = await fs.readFile(filePath, 'utf8');
89
+ for (const [name, value] of sortedEntries) {
90
+ content = content.split(`{{${name}}}`).join(value);
91
+ }
92
+ // After substitution, error if any declared placeholder remains.
93
+ for (const name of declaredNames) {
94
+ if (content.includes(`{{${name}}}`)) {
95
+ throw new Error(`Unresolved parameter placeholder {{${name}}} remains in ${path.relative(rootDir, filePath)} after substitution`);
96
+ }
97
+ }
98
+ // eslint-disable-next-line no-await-in-loop
99
+ await fs.writeFile(filePath, content, { encoding: 'utf8' });
100
+ filesWritten.push(filePath);
101
+ }
102
+ return { filesWritten };
103
+ }
104
+ async function collectYamlFiles(dir) {
105
+ const out = [];
106
+ const entries = await fs.readdir(dir, { withFileTypes: true });
107
+ for (const entry of entries) {
108
+ const full = path.join(dir, entry.name);
109
+ if (entry.isDirectory()) {
110
+ // eslint-disable-next-line no-await-in-loop
111
+ const sub = await collectYamlFiles(full);
112
+ out.push(...sub);
113
+ continue;
114
+ }
115
+ if (entry.isFile() && (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml'))) {
116
+ out.push(full);
117
+ }
118
+ }
119
+ return out;
120
+ }
@@ -31,6 +31,29 @@ export type ResolvePackageOutput = {
31
31
  };
32
32
  export declare const PackageKind: z.ZodDefault<z.ZodEnum<["app", "package"]>>;
33
33
  export type PackageKindType = z.infer<typeof PackageKind>;
34
+ export declare const PARAMETER_NAME_PATTERN: RegExp;
35
+ export declare const ParameterSpec: z.ZodEffects<z.ZodObject<{
36
+ default: z.ZodOptional<z.ZodString>;
37
+ description: z.ZodOptional<z.ZodString>;
38
+ mandatory: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
39
+ }, "strip", z.ZodTypeAny, {
40
+ description?: string | undefined;
41
+ default?: string | undefined;
42
+ mandatory?: boolean | undefined;
43
+ }, {
44
+ description?: string | undefined;
45
+ default?: string | undefined;
46
+ mandatory?: boolean | undefined;
47
+ }>, {
48
+ description?: string | undefined;
49
+ default?: string | undefined;
50
+ mandatory?: boolean | undefined;
51
+ }, {
52
+ description?: string | undefined;
53
+ default?: string | undefined;
54
+ mandatory?: boolean | undefined;
55
+ }>;
56
+ export type IParameterSpec = z.infer<typeof ParameterSpec>;
34
57
  export declare const PackageMetadata: z.ZodObject<{
35
58
  dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
36
59
  deploy: z.ZodOptional<z.ZodBoolean>;
@@ -51,12 +74,46 @@ export declare const PackageMetadata: z.ZodObject<{
51
74
  }>>;
52
75
  originalInfra: z.ZodOptional<z.ZodNativeEnum<typeof InfrastructureType>>;
53
76
  outputs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{}, "passthrough", z.ZodTypeAny, z.objectOutputType<{}, z.ZodTypeAny, "passthrough">, z.objectInputType<{}, z.ZodTypeAny, "passthrough">>>>;
77
+ parameters: z.ZodEffects<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
78
+ default: z.ZodOptional<z.ZodString>;
79
+ description: z.ZodOptional<z.ZodString>;
80
+ mandatory: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
81
+ }, "strip", z.ZodTypeAny, {
82
+ description?: string | undefined;
83
+ default?: string | undefined;
84
+ mandatory?: boolean | undefined;
85
+ }, {
86
+ description?: string | undefined;
87
+ default?: string | undefined;
88
+ mandatory?: boolean | undefined;
89
+ }>, {
90
+ description?: string | undefined;
91
+ default?: string | undefined;
92
+ mandatory?: boolean | undefined;
93
+ }, {
94
+ description?: string | undefined;
95
+ default?: string | undefined;
96
+ mandatory?: boolean | undefined;
97
+ }>>>, Record<string, {
98
+ description?: string | undefined;
99
+ default?: string | undefined;
100
+ mandatory?: boolean | undefined;
101
+ }> | undefined, Record<string, {
102
+ description?: string | undefined;
103
+ default?: string | undefined;
104
+ mandatory?: boolean | undefined;
105
+ }> | undefined>;
54
106
  snakeCase: z.ZodOptional<z.ZodBoolean>;
55
107
  }, "strip", z.ZodTypeAny, {
56
108
  infra: InfrastructureType;
57
109
  iac: IacType;
58
110
  dependencies?: Record<string, string> | undefined;
59
111
  kind?: "package" | "app" | undefined;
112
+ parameters?: Record<string, {
113
+ description?: string | undefined;
114
+ default?: string | undefined;
115
+ mandatory?: boolean | undefined;
116
+ }> | undefined;
60
117
  deploy?: boolean | undefined;
61
118
  devDeploy?: boolean | undefined;
62
119
  inputs?: Record<string, z.objectOutputType<{}, z.ZodTypeAny, "passthrough">> | undefined;
@@ -72,6 +129,11 @@ export declare const PackageMetadata: z.ZodObject<{
72
129
  iac: IacType;
73
130
  dependencies?: Record<string, string> | undefined;
74
131
  kind?: "package" | "app" | undefined;
132
+ parameters?: Record<string, {
133
+ description?: string | undefined;
134
+ default?: string | undefined;
135
+ mandatory?: boolean | undefined;
136
+ }> | undefined;
75
137
  deploy?: boolean | undefined;
76
138
  devDeploy?: boolean | undefined;
77
139
  inputs?: Record<string, z.objectInputType<{}, z.ZodTypeAny, "passthrough">> | undefined;
@@ -75,6 +75,16 @@ export async function downloadPackage(pkgUrl, destPath) {
75
75
  return packageManager.downloadPackage(pkgUrl, destPath);
76
76
  }
77
77
  export const PackageKind = z.enum(['app', 'package']).default('package');
78
+ export const PARAMETER_NAME_PATTERN = /^[A-Za-z][\w-]*$/;
79
+ export const ParameterSpec = z
80
+ .object({
81
+ default: z.string().optional(),
82
+ description: z.string().optional(),
83
+ mandatory: z.boolean().default(false).optional(),
84
+ })
85
+ .refine((p) => !(p.mandatory && p.default !== undefined), {
86
+ message: 'mandatory parameter cannot have a default',
87
+ });
78
88
  export const PackageMetadata = z.object({
79
89
  dependencies: z.record(z.string()).optional(),
80
90
  deploy: z.boolean().optional(),
@@ -91,6 +101,16 @@ export const PackageMetadata = z.object({
91
101
  .optional(),
92
102
  originalInfra: z.nativeEnum(InfrastructureType).optional(),
93
103
  outputs: z.record(z.object({}).passthrough()).optional(),
104
+ parameters: z
105
+ .record(z.string(), ParameterSpec)
106
+ .optional()
107
+ .refine((params) => {
108
+ if (!params)
109
+ return true;
110
+ return Object.keys(params).every((name) => PARAMETER_NAME_PATTERN.test(name));
111
+ }, {
112
+ message: `parameter names must match ${PARAMETER_NAME_PATTERN.source}`,
113
+ }),
94
114
  snakeCase: z.boolean().optional(),
95
115
  });
96
116
  // Helper function to parse package spec into name and version