feat: add deployment profile generation slice
This commit is contained in:
@@ -14,6 +14,6 @@
|
|||||||
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs"
|
"test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
export { loadDeploymentProfileArtifact, createDeploymentBindingContract } from './profile-artifact.mjs';
|
export {
|
||||||
|
loadDeploymentProfileArtifact,
|
||||||
|
createDeploymentBindingContract,
|
||||||
|
} from './profile-artifact.mjs';
|
||||||
|
export {
|
||||||
|
parseDeploymentProfileYaml,
|
||||||
|
validateDeploymentProfileSchema,
|
||||||
|
generateDeploymentProfileArtifact,
|
||||||
|
generateDeploymentProfileArtifactFromFile,
|
||||||
|
} from './profile-generator.mjs';
|
||||||
|
|||||||
227
plugins/reporting-governance/src/storage/profile-generator.mjs
Normal file
227
plugins/reporting-governance/src/storage/profile-generator.mjs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const packageRoot = path.resolve(import.meta.dirname, '..', '..');
|
||||||
|
const repoRoot = path.resolve(packageRoot, '..', '..');
|
||||||
|
const schemaPath = '../../../schemas/reporting-governance/deployment-profile.schema.json';
|
||||||
|
|
||||||
|
function readText(filePath) {
|
||||||
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countIndent(line) {
|
||||||
|
let count = 0;
|
||||||
|
while (count < line.length && line[count] === ' ') count += 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(raw) {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlock(lines, startIndex = 0, baseIndent = 0) {
|
||||||
|
const firstMeaningful = lines.slice(startIndex).find((line) => line.trim() !== '');
|
||||||
|
const container = firstMeaningful?.trim().startsWith('- ') ? [] : {};
|
||||||
|
let index = startIndex;
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = countIndent(line);
|
||||||
|
if (indent < baseIndent) break;
|
||||||
|
if (indent > baseIndent) {
|
||||||
|
throw new Error(`unexpected indentation at line ${index + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(container)) {
|
||||||
|
if (!trimmed.startsWith('- ')) {
|
||||||
|
throw new Error(`expected array item at line ${index + 1}`);
|
||||||
|
}
|
||||||
|
const itemBody = trimmed.slice(2);
|
||||||
|
if (itemBody === '') {
|
||||||
|
const nested = parseBlock(lines, index + 1, baseIndent + 2);
|
||||||
|
container.push(nested.value);
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (itemBody.includes(':')) {
|
||||||
|
const separator = itemBody.indexOf(':');
|
||||||
|
const key = itemBody.slice(0, separator).trim();
|
||||||
|
const remainder = itemBody.slice(separator + 1).trim();
|
||||||
|
const objectItem = {};
|
||||||
|
if (remainder === '') {
|
||||||
|
const nested = parseBlock(lines, index + 1, baseIndent + 4);
|
||||||
|
objectItem[key] = nested.value;
|
||||||
|
container.push(objectItem);
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
objectItem[key] = parseScalar(remainder);
|
||||||
|
container.push(objectItem);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
container.push(parseScalar(itemBody));
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = trimmed.indexOf(':');
|
||||||
|
if (separator === -1) {
|
||||||
|
throw new Error(`expected key/value pair at line ${index + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separator).trim();
|
||||||
|
let remainder = trimmed.slice(separator + 1).trim();
|
||||||
|
|
||||||
|
if (remainder === '>-') {
|
||||||
|
const blockLines = [];
|
||||||
|
index += 1;
|
||||||
|
while (index < lines.length) {
|
||||||
|
const nestedLine = lines[index];
|
||||||
|
const nestedTrimmed = nestedLine.trim();
|
||||||
|
if (nestedTrimmed === '') {
|
||||||
|
blockLines.push('');
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nestedIndent = countIndent(nestedLine);
|
||||||
|
if (nestedIndent <= baseIndent) break;
|
||||||
|
blockLines.push(nestedLine.slice(baseIndent + 2).trim());
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
container[key] = blockLines.join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainder === '') {
|
||||||
|
const nested = parseBlock(lines, index + 1, baseIndent + 2);
|
||||||
|
container[key] = nested.value;
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
container[key] = parseScalar(remainder);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: container, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeploymentProfileYaml(yamlText) {
|
||||||
|
const lines = yamlText.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
return parseBlock(lines, 0, 0).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDeploymentProfileSchema(profile) {
|
||||||
|
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
||||||
|
throw new Error('deployment profile must be an object');
|
||||||
|
}
|
||||||
|
if (profile.apiVersion !== 'reporting-governance/v1alpha1') {
|
||||||
|
throw new Error('deployment profile apiVersion must be reporting-governance/v1alpha1');
|
||||||
|
}
|
||||||
|
if (profile.kind !== 'DeploymentProfile') {
|
||||||
|
throw new Error('deployment profile kind must be DeploymentProfile');
|
||||||
|
}
|
||||||
|
if (typeof profile?.metadata?.id !== 'string' || profile.metadata.id.trim() === '') {
|
||||||
|
throw new Error('deployment profile metadata.id must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (typeof profile?.metadata?.version !== 'string' || profile.metadata.version.trim() === '') {
|
||||||
|
throw new Error('deployment profile metadata.version must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (typeof profile?.metadata?.runtime !== 'string' || profile.metadata.runtime.trim() === '') {
|
||||||
|
throw new Error('deployment profile metadata.runtime must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (typeof profile?.spec?.package?.pluginVersion !== 'string' || profile.spec.package.pluginVersion.trim() === '') {
|
||||||
|
throw new Error('deployment profile spec.package.pluginVersion must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (!profile?.spec?.adapters || typeof profile.spec.adapters !== 'object' || Array.isArray(profile.spec.adapters)) {
|
||||||
|
throw new Error('deployment profile spec.adapters must be an object');
|
||||||
|
}
|
||||||
|
if (!profile?.spec?.notifications || typeof profile.spec.notifications !== 'object' || Array.isArray(profile.spec.notifications)) {
|
||||||
|
throw new Error('deployment profile spec.notifications must be an object');
|
||||||
|
}
|
||||||
|
if (!profile?.spec?.audit || typeof profile.spec.audit !== 'object' || Array.isArray(profile.spec.audit)) {
|
||||||
|
throw new Error('deployment profile spec.audit must be an object');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(profile?.spec?.audit?.requiredArtifacts) || profile.spec.audit.requiredArtifacts.length === 0) {
|
||||||
|
throw new Error('deployment profile spec.audit.requiredArtifacts must be a non-empty array');
|
||||||
|
}
|
||||||
|
if (!profile?.spec?.capability_expectations || typeof profile.spec.capability_expectations !== 'object' || Array.isArray(profile.spec.capability_expectations)) {
|
||||||
|
throw new Error('deployment profile spec.capability_expectations must be an object');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(profile.spec.capability_expectations.required)) {
|
||||||
|
throw new Error('deployment profile spec.capability_expectations.required must be an array');
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDeploymentProfileArtifact(profile, { sourceProfile } = {}) {
|
||||||
|
const validated = validateDeploymentProfileSchema(profile);
|
||||||
|
const id = validated.metadata.id;
|
||||||
|
const sourceProfilePath = sourceProfile ?? path.join('profiles', `${id}.yaml`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
$schema: schemaPath,
|
||||||
|
apiVersion: validated.apiVersion,
|
||||||
|
kind: 'DeploymentProfileArtifact',
|
||||||
|
metadata: {
|
||||||
|
id,
|
||||||
|
version: validated.metadata.version,
|
||||||
|
runtime: validated.metadata.runtime,
|
||||||
|
source_profile: sourceProfilePath,
|
||||||
|
compatibility_mode: 'strict_envelope',
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
package: {
|
||||||
|
pluginVersion: validated.spec.package.pluginVersion,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
runtime: validated.metadata.runtime,
|
||||||
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
||||||
|
scripts: {
|
||||||
|
watchdog: 'scripts/long_task_watchdog.mjs',
|
||||||
|
dispatcher: 'scripts/operator_notify_dispatcher.mjs',
|
||||||
|
bridgeSupervisor: 'scripts/operator_notify_bridge_supervisor.mjs',
|
||||||
|
senderBinding: 'scripts/operator_notify_sender_binding.mjs',
|
||||||
|
orchestrator: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
||||||
|
},
|
||||||
|
artifact_roots: {
|
||||||
|
watchdogEvidence: 'state/long-task-watchdog',
|
||||||
|
canonicalEvents: 'state/long-task-watchdog-events',
|
||||||
|
queueItems: 'state/operator-notify-queue',
|
||||||
|
spoolArtifacts: 'state/operator-notify-dispatch-spool',
|
||||||
|
bridgeReceipts: 'state/operator-notify-bridge-receipts',
|
||||||
|
senderAttempts: 'state/operator-notify-sender-attempts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDeploymentProfileArtifactFromFile(profilePath) {
|
||||||
|
const absoluteProfilePath = path.resolve(profilePath);
|
||||||
|
const profile = parseDeploymentProfileYaml(readText(absoluteProfilePath));
|
||||||
|
return generateDeploymentProfileArtifact(profile, {
|
||||||
|
sourceProfile: path.relative(repoRoot, absoluteProfilePath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
parseDeploymentProfileYaml,
|
||||||
|
validateDeploymentProfileSchema,
|
||||||
|
generateDeploymentProfileArtifact,
|
||||||
|
generateDeploymentProfileArtifactFromFile,
|
||||||
|
};
|
||||||
71
plugins/reporting-governance/test/profile-generator.test.mjs
Normal file
71
plugins/reporting-governance/test/profile-generator.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseDeploymentProfileYaml,
|
||||||
|
validateDeploymentProfileSchema,
|
||||||
|
generateDeploymentProfileArtifact,
|
||||||
|
generateDeploymentProfileArtifactFromFile,
|
||||||
|
} from '../src/storage/profile-generator.mjs';
|
||||||
|
import { validateDeploymentProfileArtifact } from '../src/storage/profile-artifact.mjs';
|
||||||
|
|
||||||
|
const packageRoot = path.resolve(import.meta.dirname, '..');
|
||||||
|
const repoRoot = path.resolve(packageRoot, '..', '..');
|
||||||
|
|
||||||
|
test('deployment profile yaml parser reads strict-manager-mode profile shape', () => {
|
||||||
|
const artifact = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml'));
|
||||||
|
|
||||||
|
assert.equal(artifact.metadata.id, 'strict-manager-mode');
|
||||||
|
assert.equal(artifact.metadata.runtime, 'openclaw');
|
||||||
|
assert.equal(artifact.spec.package.pluginVersion, '0.1.0-mainline');
|
||||||
|
assert.equal(artifact.metadata.source_profile, 'profiles/strict-manager-mode.yaml');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment profile schema validator rejects malformed profile', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateDeploymentProfileSchema({ kind: 'DeploymentProfile' }),
|
||||||
|
/deployment profile apiVersion must be reporting-governance\/v1alpha1/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => validateDeploymentProfileSchema({
|
||||||
|
apiVersion: 'reporting-governance/v1alpha1',
|
||||||
|
kind: 'DeploymentProfile',
|
||||||
|
metadata: { id: 'x', version: '1.0.0', runtime: 'openclaw' },
|
||||||
|
spec: {
|
||||||
|
package: { pluginVersion: '0.1.0-mainline' },
|
||||||
|
adapters: {},
|
||||||
|
notifications: {},
|
||||||
|
audit: { requiredArtifacts: [] },
|
||||||
|
capability_expectations: { required: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/deployment profile spec\.audit\.requiredArtifacts must be a non-empty array/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yaml to artifact generation produces validator-compatible package artifact', () => {
|
||||||
|
const profile = parseDeploymentProfileYaml(`apiVersion: reporting-governance/v1alpha1\nkind: DeploymentProfile\nmetadata:\n id: demo\n version: 1.0.0\n runtime: openclaw\nspec:\n package:\n pluginVersion: 0.1.0-mainline\n adapters:\n watchdog:\n enabled: true\n queue:\n enabled: true\n dispatcher:\n enabled: true\n bridge:\n enabled: true\n sender:\n mode: openclaw-cli\n orchestrator:\n enabled: true\n notifications:\n operatorVisibleRecoveryRequired: true\n allowedTerminalStates:\n - acked\n audit:\n portableArtifactsRequired: true\n requiredArtifacts:\n - queue_items\n capability_expectations:\n required:\n - create_queue_items\n`);
|
||||||
|
const artifact = generateDeploymentProfileArtifact(profile, { sourceProfile: 'profiles/demo.yaml' });
|
||||||
|
|
||||||
|
assert.equal(artifact.kind, 'DeploymentProfileArtifact');
|
||||||
|
assert.equal(artifact.metadata.source_profile, 'profiles/demo.yaml');
|
||||||
|
assert.equal(artifact.spec.bindings.entrypoint, 'scripts/watchdog_auto_notify_orchestrator.mjs');
|
||||||
|
assert.doesNotThrow(() => validateDeploymentProfileArtifact(artifact));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strict-manager YAML generation matches checked-in package artifact snapshot', () => {
|
||||||
|
const generated = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml'));
|
||||||
|
const checkedIn = validateDeploymentProfileArtifact(
|
||||||
|
JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(packageRoot, 'profiles', 'strict-manager-mode.profile.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(generated, checkedIn);
|
||||||
|
});
|
||||||
75
schemas/reporting-governance/deployment-profile.schema.json
Normal file
75
schemas/reporting-governance/deployment-profile.schema.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://cowbay.org/schemas/reporting-governance/deployment-profile.schema.json",
|
||||||
|
"title": "Reporting Governance Deployment Profile",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["apiVersion", "kind", "metadata", "spec"],
|
||||||
|
"properties": {
|
||||||
|
"apiVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "reporting-governance/v1alpha1"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "DeploymentProfile"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["id", "version", "runtime"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"title": { "type": "string", "minLength": 1 },
|
||||||
|
"version": { "type": "string", "minLength": 1 },
|
||||||
|
"runtime": { "type": "string", "minLength": 1 },
|
||||||
|
"summary": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["package", "adapters", "notifications", "audit", "capability_expectations"],
|
||||||
|
"properties": {
|
||||||
|
"package": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["pluginVersion"],
|
||||||
|
"properties": {
|
||||||
|
"pluginVersion": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"adapters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["watchdog", "queue", "dispatcher", "bridge", "sender", "orchestrator"]
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["operatorVisibleRecoveryRequired", "allowedTerminalStates"]
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["portableArtifactsRequired", "requiredArtifacts"]
|
||||||
|
},
|
||||||
|
"capability_expectations": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["required"],
|
||||||
|
"properties": {
|
||||||
|
"required": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"preferred": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
scripts/generate_reporting_governance_profile_artifact.mjs
Normal file
25
scripts/generate_reporting_governance_profile_artifact.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { generateDeploymentProfileArtifactFromFile } from '../plugins/reporting-governance/src/storage/profile-generator.mjs';
|
||||||
|
import { validateDeploymentProfileArtifact } from '../plugins/reporting-governance/src/storage/profile-artifact.mjs';
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const [, , sourcePathArg, outputPathArg] = process.argv;
|
||||||
|
if (!sourcePathArg || !outputPathArg) {
|
||||||
|
console.error('usage: node scripts/generate_reporting_governance_profile_artifact.mjs <source-profile.yaml> <output-artifact.json>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.resolve(sourcePathArg);
|
||||||
|
const outputPath = path.resolve(outputPathArg);
|
||||||
|
const artifact = generateDeploymentProfileArtifactFromFile(sourcePath);
|
||||||
|
validateDeploymentProfileArtifact(artifact);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + '\n', 'utf8');
|
||||||
|
console.log(JSON.stringify({ ok: true, sourcePath, outputPath, profileId: artifact.metadata.id }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user