hereya-cli 0.84.0 → 0.85.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.
@@ -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';
@@ -64,6 +64,18 @@ function normalizePackageList(raw) {
64
64
  }
65
65
  return [];
66
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
+ }
78
+ }
67
79
  /**
68
80
  * Strip credentials from log strings before flushing them to the cloud.
69
81
  * Removes:
@@ -211,7 +223,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
211
223
  }
212
224
  async executeAppDeployJob(job, cloudBackend, logger) {
213
225
  const payload = job.payload;
214
- const { appName, commit, hereyaVarsYaml, parameters, repository, sha256, version, workspace } = payload;
226
+ const { appName, commit, parameters: providedParameters, repository, sha256, version, workspace } = payload;
215
227
  if (!appName || !workspace || !repository) {
216
228
  return {
217
229
  reason: 'Missing appName, workspace, or repository in app-deploy job payload',
@@ -224,17 +236,17 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
224
236
  const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
225
237
  cleanup = downloadResult.cleanup;
226
238
  const { rootDir } = downloadResult;
227
- // Apply hereyavars overrides
228
- try {
229
- const { filesWritten } = await applyHereyaVars(rootDir, hereyaVarsYaml);
230
- if (filesWritten.length > 0) {
231
- logger.info(`Wrote ${filesWritten.length} hereyavars override file(s).`);
232
- }
233
- }
234
- catch (error) {
235
- await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: error.message, workspace }, cloudBackend);
236
- 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 };
237
248
  }
249
+ const { parameters: resolvedParameters } = paramsResult;
238
250
  // Read hereya.yaml from app source
239
251
  const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
240
252
  if (!hereyaYamlPath) {
@@ -256,6 +268,15 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
256
268
  status: 'deploying',
257
269
  workspace,
258
270
  });
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
+ }
259
280
  const mergedEnv = {};
260
281
  // 1) Provision regular packages
261
282
  /* eslint-disable no-await-in-loop */
@@ -264,7 +285,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
264
285
  isDeploying: true,
265
286
  logger,
266
287
  package: pkg.spec,
267
- parameters,
288
+ parameters: resolvedParameters,
268
289
  projectRootDir: rootDir,
269
290
  workspace,
270
291
  });
@@ -280,7 +301,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
280
301
  isDeploying: true,
281
302
  logger,
282
303
  package: pkg.spec,
283
- parameters,
304
+ parameters: resolvedParameters,
284
305
  projectEnv: { ...workspaceEnv, ...mergedEnv },
285
306
  projectRootDir: rootDir,
286
307
  workspace,
@@ -327,7 +348,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
327
348
  }
328
349
  async executeAppDestroyJob(job, cloudBackend, logger) {
329
350
  const payload = job.payload;
330
- const { appName, commit, hereyaVarsYaml, parameters, repository, sha256, workspace } = payload;
351
+ const { appName, commit, parameters: providedParameters, repository, sha256, workspace } = payload;
331
352
  if (!appName || !workspace || !repository) {
332
353
  return {
333
354
  reason: 'Missing appName, workspace, or repository in app-destroy job payload',
@@ -340,13 +361,22 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
340
361
  const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
341
362
  cleanup = downloadResult.cleanup;
342
363
  const { rootDir } = downloadResult;
343
- // Apply hereyavars overrides destroy needs the same vars to drive Terraform.
344
- try {
345
- await applyHereyaVars(rootDir, hereyaVarsYaml);
346
- }
347
- catch (error) {
348
- return { reason: `Failed to apply hereyavars overrides: ${error.message}`, success: false };
364
+ // Re-read declarations + substitute hereyavars before any package work.
365
+ const paramsResult = await this.resolveAndSubstituteParams({
366
+ logger,
367
+ provided: providedParameters,
368
+ rootDir,
369
+ });
370
+ if (!paramsResult.success) {
371
+ await cloudBackend.updateAppDeployment({
372
+ lastJobId: job.id,
373
+ name: appName,
374
+ status: 'failed',
375
+ workspace,
376
+ });
377
+ return { reason: paramsResult.reason, success: false };
349
378
  }
379
+ const { parameters: resolvedParameters } = paramsResult;
350
380
  const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
351
381
  if (!hereyaYamlPath) {
352
382
  return { reason: 'App source does not contain a hereya.yaml file', success: false };
@@ -361,6 +391,20 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
361
391
  status: 'destroying',
362
392
  workspace,
363
393
  });
394
+ const preDeployResult = await runPreDeployCommand({
395
+ command: parsedHereyaYaml.preDeployCommand,
396
+ directory: rootDir,
397
+ logger,
398
+ });
399
+ if (!preDeployResult.success) {
400
+ await cloudBackend.updateAppDeployment({
401
+ lastJobId: job.id,
402
+ name: appName,
403
+ status: 'failed',
404
+ workspace,
405
+ });
406
+ return { reason: preDeployResult.reason, success: false };
407
+ }
364
408
  // Iterate in REVERSE: deploy packages first (depend on regular pkgs), then regular pkgs.
365
409
  const reversed = [...deployPackages.reverse(), ...packages.reverse()];
366
410
  const workspaceEnvResult = await cloudBackend.getWorkspaceEnv({ workspace });
@@ -371,7 +415,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
371
415
  isDeploying: true,
372
416
  logger,
373
417
  package: pkg.spec,
374
- parameters,
418
+ parameters: resolvedParameters,
375
419
  projectEnv: workspaceEnv,
376
420
  projectRootDir: rootDir,
377
421
  workspace,
@@ -741,4 +785,34 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
741
785
  // best-effort; failure of the PATCH must not bubble out
742
786
  }
743
787
  }
788
+ async readDeclaredParameters(rootDir) {
789
+ const hereyarcPath = await getAnyExisting(path.join(rootDir, 'hereyarc.yaml'), path.join(rootDir, 'hereyarc.yml'));
790
+ if (!hereyarcPath) {
791
+ return {};
792
+ }
793
+ const content = await fs.readFile(hereyarcPath, 'utf8');
794
+ const parsed = (yaml.parse(content) ?? {});
795
+ return parsed.parameters ?? {};
796
+ }
797
+ async resolveAndSubstituteParams(input) {
798
+ const declared = await this.readDeclaredParameters(input.rootDir);
799
+ const resolved = resolveAppParameters({ declared, provided: input.provided });
800
+ if (resolved.errors.length > 0) {
801
+ return { reason: `Parameter validation failed: ${resolved.errors.join('; ')}`, success: false };
802
+ }
803
+ try {
804
+ const { filesWritten } = await substituteAppParameters({
805
+ declaredNames: Object.keys(declared),
806
+ parameters: resolved.parameters,
807
+ rootDir: input.rootDir,
808
+ });
809
+ if (filesWritten.length > 0) {
810
+ input.logger.info(`Substituted parameters in ${filesWritten.length} hereyavars file(s).`);
811
+ }
812
+ }
813
+ catch (error) {
814
+ return { reason: `Failed to substitute hereyavars parameters: ${error.message}`, success: false };
815
+ }
816
+ return { parameters: resolved.parameters, success: true };
817
+ }
744
818
  }
@@ -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;