hereya-cli 0.84.1 → 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';
@@ -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) {
@@ -285,7 +285,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
285
285
  isDeploying: true,
286
286
  logger,
287
287
  package: pkg.spec,
288
- parameters,
288
+ parameters: resolvedParameters,
289
289
  projectRootDir: rootDir,
290
290
  workspace,
291
291
  });
@@ -301,7 +301,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
301
301
  isDeploying: true,
302
302
  logger,
303
303
  package: pkg.spec,
304
- parameters,
304
+ parameters: resolvedParameters,
305
305
  projectEnv: { ...workspaceEnv, ...mergedEnv },
306
306
  projectRootDir: rootDir,
307
307
  workspace,
@@ -348,7 +348,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
348
348
  }
349
349
  async executeAppDestroyJob(job, cloudBackend, logger) {
350
350
  const payload = job.payload;
351
- const { appName, commit, hereyaVarsYaml, parameters, repository, sha256, workspace } = payload;
351
+ const { appName, commit, parameters: providedParameters, repository, sha256, workspace } = payload;
352
352
  if (!appName || !workspace || !repository) {
353
353
  return {
354
354
  reason: 'Missing appName, workspace, or repository in app-destroy job payload',
@@ -361,13 +361,22 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
361
361
  const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
362
362
  cleanup = downloadResult.cleanup;
363
363
  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 };
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 };
370
378
  }
379
+ const { parameters: resolvedParameters } = paramsResult;
371
380
  const hereyaYamlPath = await getAnyExisting(path.join(rootDir, 'hereya.yaml'), path.join(rootDir, 'hereya.yml'));
372
381
  if (!hereyaYamlPath) {
373
382
  return { reason: 'App source does not contain a hereya.yaml file', success: false };
@@ -406,7 +415,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
406
415
  isDeploying: true,
407
416
  logger,
408
417
  package: pkg.spec,
409
- parameters,
418
+ parameters: resolvedParameters,
410
419
  projectEnv: workspaceEnv,
411
420
  projectRootDir: rootDir,
412
421
  workspace,
@@ -776,4 +785,34 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
776
785
  // best-effort; failure of the PATCH must not bubble out
777
786
  }
778
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
+ }
779
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;
@@ -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