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.
- package/README.md +66 -71
- package/dist/backend/cloud/cloud-backend.d.ts +13 -1
- package/dist/backend/cloud/cloud-backend.js +23 -2
- package/dist/commands/app/deploy/index.d.ts +0 -2
- package/dist/commands/app/deploy/index.js +15 -29
- package/dist/commands/executor/start/index.d.ts +2 -0
- package/dist/commands/executor/start/index.js +61 -22
- package/dist/commands/publish/index.d.ts +3 -0
- package/dist/commands/publish/index.js +25 -0
- package/dist/lib/app-parameters.d.ts +41 -0
- package/dist/lib/app-parameters.js +120 -0
- package/dist/lib/package/index.d.ts +62 -0
- package/dist/lib/package/index.js +20 -0
- package/oclif.manifest.json +92 -114
- package/package.json +1 -1
- package/dist/lib/app-vars.d.ts +0 -10
- package/dist/lib/app-vars.js +0 -45
|
@@ -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 -
|
|
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
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
@@ -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,
|
|
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
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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,
|
|
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
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|