@formigio/fazemos-cli 0.2.3 → 0.3.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/dist/index.js +881 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -697,8 +697,44 @@ commitments
|
|
|
697
697
|
process.exit(1);
|
|
698
698
|
}
|
|
699
699
|
});
|
|
700
|
+
// ── Template I/O helpers ───────────────────────────────────
|
|
701
|
+
function allSteps(definition) {
|
|
702
|
+
const steps = [];
|
|
703
|
+
for (const phase of definition?.phases || []) {
|
|
704
|
+
for (const step of phase.steps || []) {
|
|
705
|
+
steps.push(step);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return steps;
|
|
709
|
+
}
|
|
710
|
+
function findStepById(definition, stepId) {
|
|
711
|
+
return allSteps(definition).find((s) => s.id === stepId);
|
|
712
|
+
}
|
|
713
|
+
const VALID_IO_TYPES = ['text', 'markdown', 'number', 'boolean', 'url', 'json', 'object', 'array'];
|
|
714
|
+
function requireDraftStatus(template) {
|
|
715
|
+
if (template.status !== 'draft') {
|
|
716
|
+
console.error(chalk.yellow(`Template is "${template.status}" — edits require draft status`));
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function findPhaseById(definition, phaseId) {
|
|
721
|
+
return (definition?.phases || []).find((p) => p.id === phaseId);
|
|
722
|
+
}
|
|
723
|
+
const VALID_STEP_TYPES = ['human', 'agent', 'script', 'gate'];
|
|
700
724
|
// ── Templates ──────────────────────────────────────────────
|
|
701
|
-
const templates = program.command('templates').alias('tpl').description('Pipeline template commands'
|
|
725
|
+
const templates = program.command('templates').alias('tpl').description('Pipeline template commands.\n\n' +
|
|
726
|
+
' Templates define multi-step workflows: template → phases → steps → I/O.\n' +
|
|
727
|
+
' Lifecycle: draft → active → archived (archived → draft to unarchive).\n' +
|
|
728
|
+
' Structural edits (add/remove/edit phases, steps, I/O) require draft status.\n\n' +
|
|
729
|
+
' Typical workflow:\n' +
|
|
730
|
+
' 1. tpl create -n "Name" Create empty draft template\n' +
|
|
731
|
+
' 2. tpl add-phase <id> --name ... Add phases\n' +
|
|
732
|
+
' 3. tpl add-step <id> --phase ... Add steps to phases\n' +
|
|
733
|
+
' 4. tpl add-output / add-input Wire I/O between steps\n' +
|
|
734
|
+
' 5. tpl validate <id> Check for errors\n' +
|
|
735
|
+
' 6. tpl activate <id> Make available for instances\n\n' +
|
|
736
|
+
' Use "tpl steps <id>" to list step IDs needed by --step options.\n' +
|
|
737
|
+
' Use "tpl show <id>" to see full structure with I/O declarations.');
|
|
702
738
|
templates
|
|
703
739
|
.command('list')
|
|
704
740
|
.description('List pipeline templates')
|
|
@@ -724,8 +760,8 @@ templates
|
|
|
724
760
|
});
|
|
725
761
|
templates
|
|
726
762
|
.command('show')
|
|
727
|
-
.description('Show template detail')
|
|
728
|
-
.argument('<id>', 'Template ID')
|
|
763
|
+
.description('Show template detail including phases, steps, I/O declarations, and pipeline inputs. Use this to inspect the full structure of a template and discover phase/step IDs needed by other commands.')
|
|
764
|
+
.argument('<id>', 'Template ID (use "tpl list" to find IDs)')
|
|
729
765
|
.action(async (id) => {
|
|
730
766
|
try {
|
|
731
767
|
const data = await api('GET', `/api/pipeline-templates/${id}`);
|
|
@@ -734,11 +770,45 @@ templates
|
|
|
734
770
|
console.log(` ID: ${t.id}`);
|
|
735
771
|
console.log(` Status: ${t.status}`);
|
|
736
772
|
console.log(` Version: ${t.version}`);
|
|
773
|
+
// Pipeline-level inputs
|
|
774
|
+
if (t.definition?.inputs?.length) {
|
|
775
|
+
console.log(chalk.cyan('\n Pipeline Inputs:'));
|
|
776
|
+
for (const inp of t.definition.inputs) {
|
|
777
|
+
const req = inp.required === false ? 'optional' : 'required';
|
|
778
|
+
const desc = inp.description ? ` — ${inp.description}` : '';
|
|
779
|
+
const def = inp.default_value != null ? ` [default: ${inp.default_value}]` : '';
|
|
780
|
+
console.log(` ${inp.name} (${inp.type}, ${req})${desc}${def}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
737
783
|
if (t.definition?.phases) {
|
|
738
784
|
for (const phase of t.definition.phases) {
|
|
739
785
|
console.log(chalk.cyan(`\n Phase: ${phase.name}`));
|
|
740
786
|
for (const step of phase.steps || []) {
|
|
741
|
-
console.log(` ${step.name}
|
|
787
|
+
console.log(` Step: ${step.name} [${step.role || 'unassigned'}] (${step.step_type || step.stepType || 'human'})`);
|
|
788
|
+
if (step.outputs?.length) {
|
|
789
|
+
console.log(' Outputs:');
|
|
790
|
+
for (const o of step.outputs) {
|
|
791
|
+
const req = o.required === false ? 'optional' : 'required';
|
|
792
|
+
const desc = o.description ? ` — ${o.description}` : '';
|
|
793
|
+
console.log(` → ${o.name} (${o.type}, ${req})${desc}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (step.inputs?.length) {
|
|
797
|
+
console.log(' Inputs:');
|
|
798
|
+
for (const inp of step.inputs) {
|
|
799
|
+
if (inp.pipeline_input) {
|
|
800
|
+
console.log(` ← ${inp.name} ← pipeline.${inp.pipeline_input}`);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
const srcStep = findStepById(t.definition, inp.source_step_id);
|
|
804
|
+
const srcName = srcStep ? srcStep.name : inp.source_step_id;
|
|
805
|
+
console.log(` ← ${inp.name} ← ${srcName}.${inp.source_output_name}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
else if (step.outputs?.length) {
|
|
810
|
+
console.log(' Inputs: none');
|
|
811
|
+
}
|
|
742
812
|
}
|
|
743
813
|
}
|
|
744
814
|
}
|
|
@@ -750,7 +820,7 @@ templates
|
|
|
750
820
|
});
|
|
751
821
|
templates
|
|
752
822
|
.command('create')
|
|
753
|
-
.description('Create
|
|
823
|
+
.description('Create an empty pipeline template in draft status. After creation, use add-phase, add-step, and I/O commands to build the structure, then activate.')
|
|
754
824
|
.requiredOption('-n, --name <name>', 'Template name')
|
|
755
825
|
.option('-d, --description <desc>', 'Description')
|
|
756
826
|
.action(async (opts) => {
|
|
@@ -770,30 +840,52 @@ templates
|
|
|
770
840
|
});
|
|
771
841
|
templates
|
|
772
842
|
.command('import')
|
|
773
|
-
.description('Import a JOE template JSON file as a Fazemos pipeline template')
|
|
843
|
+
.description('Import a JOE template JSON file as a Fazemos pipeline template. Imports steps, outputs, inputs, and pipeline-level inputs. Step IDs are remapped to new UUIDs and input references are updated accordingly.')
|
|
774
844
|
.argument('<file>', 'Path to JOE template JSON file')
|
|
775
845
|
.action(async (file) => {
|
|
776
846
|
try {
|
|
777
|
-
const { readFileSync } = await import('fs');
|
|
778
|
-
const { resolve } = await import('path');
|
|
779
847
|
const raw = JSON.parse(readFileSync(resolve(file), 'utf-8'));
|
|
780
848
|
// Map JOE template to Fazemos format
|
|
781
|
-
const
|
|
782
|
-
//
|
|
849
|
+
const joeSteps = raw.steps || [];
|
|
850
|
+
// Build ID mapping so input source_step_id references survive UUID reassignment
|
|
851
|
+
const idMap = new Map();
|
|
852
|
+
for (const s of joeSteps) {
|
|
853
|
+
if (s.id)
|
|
854
|
+
idMap.set(s.id, crypto.randomUUID());
|
|
855
|
+
}
|
|
783
856
|
const definition = {
|
|
857
|
+
inputs: (raw.inputs || []).map((i) => ({
|
|
858
|
+
name: i.name,
|
|
859
|
+
type: i.type || 'text',
|
|
860
|
+
required: i.required !== false,
|
|
861
|
+
...(i.description ? { description: i.description } : {}),
|
|
862
|
+
...(i.default_value != null ? { default_value: i.default_value } : {}),
|
|
863
|
+
})),
|
|
784
864
|
phases: [{
|
|
785
865
|
id: crypto.randomUUID(),
|
|
786
866
|
name: raw.name || 'Main',
|
|
787
867
|
description: raw.description || '',
|
|
788
868
|
deliverables: [],
|
|
789
|
-
steps:
|
|
790
|
-
id: crypto.randomUUID(),
|
|
869
|
+
steps: joeSteps.map((s, i) => ({
|
|
870
|
+
id: idMap.get(s.id) || crypto.randomUUID(),
|
|
791
871
|
name: s.name,
|
|
792
872
|
description: s.description || '',
|
|
793
873
|
step_type: s.executionMode === 'script' ? 'script' : (s.agent ? 'agent' : 'human'),
|
|
794
874
|
role: s.role || s.agent || 'unassigned',
|
|
795
|
-
inputs: []
|
|
796
|
-
|
|
875
|
+
inputs: (s.inputs || []).map((inp) => ({
|
|
876
|
+
name: inp.name,
|
|
877
|
+
...(inp.source_step_id ? { source_step_id: idMap.get(inp.source_step_id) || inp.source_step_id, source_output_name: inp.source_output_name } : {}),
|
|
878
|
+
...(inp.pipeline_input ? { pipeline_input: inp.pipeline_input } : {}),
|
|
879
|
+
...(inp.description ? { description: inp.description } : {}),
|
|
880
|
+
required: inp.required !== false,
|
|
881
|
+
})),
|
|
882
|
+
outputs: (s.outputs || []).map((o) => ({
|
|
883
|
+
name: o.name,
|
|
884
|
+
type: o.type || 'text',
|
|
885
|
+
...(o.description ? { description: o.description } : {}),
|
|
886
|
+
required: o.required !== false,
|
|
887
|
+
...(o.format ? { format: o.format } : {}),
|
|
888
|
+
})),
|
|
797
889
|
sections: s.sections || '',
|
|
798
890
|
reviewer: s.reviewer || null,
|
|
799
891
|
max_review_cycles: s.maxReviewCycles || 0,
|
|
@@ -813,7 +905,7 @@ templates
|
|
|
813
905
|
};
|
|
814
906
|
const data = await api('POST', '/api/pipeline-templates', body);
|
|
815
907
|
const t = data.template;
|
|
816
|
-
console.log(chalk.green(`Imported: ${t.name} (${
|
|
908
|
+
console.log(chalk.green(`Imported: ${t.name} (${joeSteps.length} steps)`));
|
|
817
909
|
console.log(` ID: ${t.id}`);
|
|
818
910
|
}
|
|
819
911
|
catch (err) {
|
|
@@ -821,9 +913,25 @@ templates
|
|
|
821
913
|
process.exit(1);
|
|
822
914
|
}
|
|
823
915
|
});
|
|
916
|
+
templates
|
|
917
|
+
.command('update-definition')
|
|
918
|
+
.description('Update a template definition from a JSON file')
|
|
919
|
+
.argument('<id>', 'Template ID')
|
|
920
|
+
.argument('<file>', 'Path to definition JSON file')
|
|
921
|
+
.action(async (id, file) => {
|
|
922
|
+
try {
|
|
923
|
+
const definition = JSON.parse(readFileSync(resolve(file), 'utf-8'));
|
|
924
|
+
await api('PUT', `/api/pipeline-templates/${id}`, { definition });
|
|
925
|
+
console.log(chalk.green('Template definition updated'));
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
console.error(chalk.red(err.message));
|
|
929
|
+
process.exit(1);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
824
932
|
templates
|
|
825
933
|
.command('activate')
|
|
826
|
-
.description('Activate a template (required before creating instances)')
|
|
934
|
+
.description('Activate a draft template (required before creating pipeline instances). Template must have at least one phase with at least one step. Status changes from draft → active.')
|
|
827
935
|
.argument('<id>', 'Template ID')
|
|
828
936
|
.action(async (id) => {
|
|
829
937
|
try {
|
|
@@ -835,6 +943,763 @@ templates
|
|
|
835
943
|
process.exit(1);
|
|
836
944
|
}
|
|
837
945
|
});
|
|
946
|
+
// ── Template structure commands ─────────────────────────────
|
|
947
|
+
templates
|
|
948
|
+
.command('update')
|
|
949
|
+
.description('Update template name or description. Works on any status (draft, active, or archived). Does not modify the definition or bump version.')
|
|
950
|
+
.argument('<id>', 'Template ID')
|
|
951
|
+
.option('-n, --name <name>', 'New name')
|
|
952
|
+
.option('-d, --description <desc>', 'New description')
|
|
953
|
+
.action(async (id, opts) => {
|
|
954
|
+
try {
|
|
955
|
+
if (!opts.name && opts.description == null) {
|
|
956
|
+
console.error(chalk.red('Provide --name and/or --description'));
|
|
957
|
+
process.exit(1);
|
|
958
|
+
}
|
|
959
|
+
const body = {};
|
|
960
|
+
if (opts.name)
|
|
961
|
+
body.name = opts.name;
|
|
962
|
+
if (opts.description != null)
|
|
963
|
+
body.description = opts.description;
|
|
964
|
+
const data = await api('PUT', `/api/pipeline-templates/${id}`, body);
|
|
965
|
+
console.log(chalk.green(`Updated: ${data.template.name}`));
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
console.error(chalk.red(err.message));
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
templates
|
|
973
|
+
.command('archive')
|
|
974
|
+
.description('Archive an active template. Archived templates cannot be used to create new instances. Use "tpl unarchive" to move back to draft for editing.')
|
|
975
|
+
.argument('<id>', 'Template ID')
|
|
976
|
+
.action(async (id) => {
|
|
977
|
+
try {
|
|
978
|
+
await api('PATCH', `/api/pipeline-templates/${id}/status`, { status: 'archived' });
|
|
979
|
+
console.log(chalk.green('Template archived'));
|
|
980
|
+
}
|
|
981
|
+
catch (err) {
|
|
982
|
+
console.error(chalk.red(err.message));
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
templates
|
|
987
|
+
.command('unarchive')
|
|
988
|
+
.description('Move an archived template back to draft status for editing. After edits, use "tpl activate" to make it available again.')
|
|
989
|
+
.argument('<id>', 'Template ID')
|
|
990
|
+
.action(async (id) => {
|
|
991
|
+
try {
|
|
992
|
+
await api('PATCH', `/api/pipeline-templates/${id}/status`, { status: 'draft' });
|
|
993
|
+
console.log(chalk.green('Template moved to draft'));
|
|
994
|
+
}
|
|
995
|
+
catch (err) {
|
|
996
|
+
console.error(chalk.red(err.message));
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
templates
|
|
1001
|
+
.command('add-phase')
|
|
1002
|
+
.description('Add a phase to a template. Phases group steps into logical stages (e.g., "Design", "Build", "Test"). Template must be in draft status. Phase names must be unique within the template. Returns the generated phase ID needed by add-step.')
|
|
1003
|
+
.argument('<templateId>', 'Template ID')
|
|
1004
|
+
.requiredOption('--name <name>', 'Phase name (must be unique within the template)')
|
|
1005
|
+
.option('--description <desc>', 'Phase description')
|
|
1006
|
+
.action(async (templateId, opts) => {
|
|
1007
|
+
try {
|
|
1008
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1009
|
+
const t = data.template;
|
|
1010
|
+
requireDraftStatus(t);
|
|
1011
|
+
if (!t.definition.phases)
|
|
1012
|
+
t.definition.phases = [];
|
|
1013
|
+
if (t.definition.phases.find((p) => p.name === opts.name)) {
|
|
1014
|
+
console.error(chalk.red(`Phase "${opts.name}" already exists`));
|
|
1015
|
+
process.exit(1);
|
|
1016
|
+
}
|
|
1017
|
+
const phase = {
|
|
1018
|
+
id: crypto.randomUUID(),
|
|
1019
|
+
name: opts.name,
|
|
1020
|
+
description: opts.description || '',
|
|
1021
|
+
deliverables: [],
|
|
1022
|
+
steps: [],
|
|
1023
|
+
};
|
|
1024
|
+
t.definition.phases.push(phase);
|
|
1025
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1026
|
+
console.log(chalk.green(`Added phase: ${opts.name}`));
|
|
1027
|
+
console.log(` ID: ${phase.id}`);
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
console.error(chalk.red(err.message));
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
templates
|
|
1035
|
+
.command('remove-phase')
|
|
1036
|
+
.description('Remove a phase from a template. Blocked if the phase contains steps unless --force is used. Template must be in draft status. Use "tpl show" to find phase IDs.')
|
|
1037
|
+
.argument('<templateId>', 'Template ID')
|
|
1038
|
+
.requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" output)')
|
|
1039
|
+
.option('--force', 'Remove even if phase contains steps')
|
|
1040
|
+
.action(async (templateId, opts) => {
|
|
1041
|
+
try {
|
|
1042
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1043
|
+
const t = data.template;
|
|
1044
|
+
requireDraftStatus(t);
|
|
1045
|
+
const idx = (t.definition.phases || []).findIndex((p) => p.id === opts.phase);
|
|
1046
|
+
if (idx === -1) {
|
|
1047
|
+
console.error(chalk.red(`Phase "${opts.phase}" not found`));
|
|
1048
|
+
process.exit(1);
|
|
1049
|
+
}
|
|
1050
|
+
const phase = t.definition.phases[idx];
|
|
1051
|
+
if (phase.steps?.length && !opts.force) {
|
|
1052
|
+
console.error(chalk.yellow(`Phase "${phase.name}" has ${phase.steps.length} steps`));
|
|
1053
|
+
console.error(chalk.yellow('Use --force to remove anyway'));
|
|
1054
|
+
process.exit(1);
|
|
1055
|
+
}
|
|
1056
|
+
t.definition.phases.splice(idx, 1);
|
|
1057
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1058
|
+
console.log(chalk.green(`Removed phase: ${phase.name}`));
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
console.error(chalk.red(err.message));
|
|
1062
|
+
process.exit(1);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
templates
|
|
1066
|
+
.command('edit-phase')
|
|
1067
|
+
.description('Edit a phase name or description. Template must be in draft status. Provide at least one of --name or --description.')
|
|
1068
|
+
.argument('<templateId>', 'Template ID')
|
|
1069
|
+
.requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" output)')
|
|
1070
|
+
.option('--name <name>', 'New phase name (must be unique within the template)')
|
|
1071
|
+
.option('--description <desc>', 'New phase description')
|
|
1072
|
+
.action(async (templateId, opts) => {
|
|
1073
|
+
try {
|
|
1074
|
+
if (!opts.name && opts.description == null) {
|
|
1075
|
+
console.error(chalk.red('Provide --name and/or --description'));
|
|
1076
|
+
process.exit(1);
|
|
1077
|
+
}
|
|
1078
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1079
|
+
const t = data.template;
|
|
1080
|
+
requireDraftStatus(t);
|
|
1081
|
+
const phase = findPhaseById(t.definition, opts.phase);
|
|
1082
|
+
if (!phase) {
|
|
1083
|
+
console.error(chalk.red(`Phase "${opts.phase}" not found`));
|
|
1084
|
+
process.exit(1);
|
|
1085
|
+
}
|
|
1086
|
+
if (opts.name) {
|
|
1087
|
+
if (t.definition.phases.find((p) => p.id !== opts.phase && p.name === opts.name)) {
|
|
1088
|
+
console.error(chalk.red(`Phase name "${opts.name}" already exists`));
|
|
1089
|
+
process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
phase.name = opts.name;
|
|
1092
|
+
}
|
|
1093
|
+
if (opts.description != null)
|
|
1094
|
+
phase.description = opts.description;
|
|
1095
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1096
|
+
console.log(chalk.green(`Updated phase: ${phase.name}`));
|
|
1097
|
+
}
|
|
1098
|
+
catch (err) {
|
|
1099
|
+
console.error(chalk.red(err.message));
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
templates
|
|
1104
|
+
.command('add-step')
|
|
1105
|
+
.description('Add a step to a phase. Template must be in draft status. Step names must be unique within their phase. Returns the generated step ID needed by I/O commands (add-output, add-input, etc.). Use "tpl show" or "tpl steps" to find existing step IDs.')
|
|
1106
|
+
.argument('<templateId>', 'Template ID')
|
|
1107
|
+
.requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" or "tpl add-phase" output)')
|
|
1108
|
+
.requiredOption('--name <name>', 'Step name (must be unique within the phase)')
|
|
1109
|
+
.option('--type <type>', 'Step type: human (manual task), agent (AI agent), script (automated), gate (approval checkpoint)', 'human')
|
|
1110
|
+
.option('--role <role>', 'Role or agent name (e.g., "kate", "marco", "dev-team")')
|
|
1111
|
+
.option('--description <desc>', 'Step description')
|
|
1112
|
+
.option('--reviewer <reviewer>', 'Reviewer role for review steps')
|
|
1113
|
+
.option('--max-review-cycles <n>', 'Max review cycles before auto-approval', '0')
|
|
1114
|
+
.option('--parallel-group <group>', 'Group name for parallel execution (steps in the same group run concurrently)')
|
|
1115
|
+
.action(async (templateId, opts) => {
|
|
1116
|
+
try {
|
|
1117
|
+
if (!VALID_STEP_TYPES.includes(opts.type)) {
|
|
1118
|
+
console.error(chalk.red(`Invalid step type "${opts.type}". Valid: ${VALID_STEP_TYPES.join(', ')}`));
|
|
1119
|
+
process.exit(1);
|
|
1120
|
+
}
|
|
1121
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1122
|
+
const t = data.template;
|
|
1123
|
+
requireDraftStatus(t);
|
|
1124
|
+
const phase = findPhaseById(t.definition, opts.phase);
|
|
1125
|
+
if (!phase) {
|
|
1126
|
+
console.error(chalk.red(`Phase "${opts.phase}" not found`));
|
|
1127
|
+
process.exit(1);
|
|
1128
|
+
}
|
|
1129
|
+
if (!phase.steps)
|
|
1130
|
+
phase.steps = [];
|
|
1131
|
+
if (phase.steps.find((s) => s.name === opts.name)) {
|
|
1132
|
+
console.error(chalk.red(`Step "${opts.name}" already exists in phase "${phase.name}"`));
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
const step = {
|
|
1136
|
+
id: crypto.randomUUID(),
|
|
1137
|
+
name: opts.name,
|
|
1138
|
+
description: opts.description || '',
|
|
1139
|
+
step_type: opts.type,
|
|
1140
|
+
role: opts.role || 'unassigned',
|
|
1141
|
+
inputs: [],
|
|
1142
|
+
outputs: [],
|
|
1143
|
+
sections: '',
|
|
1144
|
+
reviewer: opts.reviewer || null,
|
|
1145
|
+
max_review_cycles: parseInt(opts.maxReviewCycles) || 0,
|
|
1146
|
+
execution_config: null,
|
|
1147
|
+
parallel_group: opts.parallelGroup || null,
|
|
1148
|
+
sort_order: phase.steps.length,
|
|
1149
|
+
};
|
|
1150
|
+
phase.steps.push(step);
|
|
1151
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1152
|
+
console.log(chalk.green(`Added step: ${opts.name} (${opts.type}) to phase ${phase.name}`));
|
|
1153
|
+
console.log(` ID: ${step.id}`);
|
|
1154
|
+
}
|
|
1155
|
+
catch (err) {
|
|
1156
|
+
console.error(chalk.red(err.message));
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
templates
|
|
1161
|
+
.command('remove-step')
|
|
1162
|
+
.description('Remove a step from a template. Blocked if other steps reference this step\'s outputs as inputs unless --force is used. Template must be in draft status. Use "tpl steps" to find step IDs.')
|
|
1163
|
+
.argument('<templateId>', 'Template ID')
|
|
1164
|
+
.requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
|
|
1165
|
+
.option('--force', 'Remove even if other steps reference this step\'s outputs')
|
|
1166
|
+
.action(async (templateId, opts) => {
|
|
1167
|
+
try {
|
|
1168
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1169
|
+
const t = data.template;
|
|
1170
|
+
requireDraftStatus(t);
|
|
1171
|
+
// Find the step and its phase
|
|
1172
|
+
let targetPhase = null;
|
|
1173
|
+
let stepIdx = -1;
|
|
1174
|
+
for (const phase of t.definition.phases || []) {
|
|
1175
|
+
const idx = (phase.steps || []).findIndex((s) => s.id === opts.step);
|
|
1176
|
+
if (idx !== -1) {
|
|
1177
|
+
targetPhase = phase;
|
|
1178
|
+
stepIdx = idx;
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (!targetPhase) {
|
|
1183
|
+
console.error(chalk.red(`Step "${opts.step}" not found`));
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
1186
|
+
const step = targetPhase.steps[stepIdx];
|
|
1187
|
+
// Check for references from other steps
|
|
1188
|
+
const refs = allSteps(t.definition).filter((s) => s.id !== step.id && (s.inputs || []).some((inp) => inp.source_step_id === step.id));
|
|
1189
|
+
if (refs.length && !opts.force) {
|
|
1190
|
+
console.error(chalk.yellow(`Step "${step.name}" is referenced by: ${refs.map((r) => r.name).join(', ')}`));
|
|
1191
|
+
console.error(chalk.yellow('Use --force to remove anyway'));
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
targetPhase.steps.splice(stepIdx, 1);
|
|
1195
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1196
|
+
console.log(chalk.green(`Removed step: ${step.name}`));
|
|
1197
|
+
}
|
|
1198
|
+
catch (err) {
|
|
1199
|
+
console.error(chalk.red(err.message));
|
|
1200
|
+
process.exit(1);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
templates
|
|
1204
|
+
.command('edit-step')
|
|
1205
|
+
.description('Edit step properties. Template must be in draft status. Provide at least one field to update. Use "tpl steps" to find step IDs.')
|
|
1206
|
+
.argument('<templateId>', 'Template ID')
|
|
1207
|
+
.requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
|
|
1208
|
+
.option('--name <name>', 'New step name (must be unique within the phase)')
|
|
1209
|
+
.option('--type <type>', 'Step type: human, agent, script, gate')
|
|
1210
|
+
.option('--role <role>', 'Role or agent name')
|
|
1211
|
+
.option('--description <desc>', 'Description')
|
|
1212
|
+
.option('--reviewer <reviewer>', 'Reviewer role (empty string to clear)')
|
|
1213
|
+
.option('--max-review-cycles <n>', 'Max review cycles')
|
|
1214
|
+
.option('--parallel-group <group>', 'Parallel execution group (empty string to clear)')
|
|
1215
|
+
.action(async (templateId, opts) => {
|
|
1216
|
+
try {
|
|
1217
|
+
const hasUpdate = opts.name || opts.type || opts.role || opts.description != null
|
|
1218
|
+
|| opts.reviewer != null || opts.maxReviewCycles != null || opts.parallelGroup != null;
|
|
1219
|
+
if (!hasUpdate) {
|
|
1220
|
+
console.error(chalk.red('Provide at least one field to update'));
|
|
1221
|
+
process.exit(1);
|
|
1222
|
+
}
|
|
1223
|
+
if (opts.type && !VALID_STEP_TYPES.includes(opts.type)) {
|
|
1224
|
+
console.error(chalk.red(`Invalid step type "${opts.type}". Valid: ${VALID_STEP_TYPES.join(', ')}`));
|
|
1225
|
+
process.exit(1);
|
|
1226
|
+
}
|
|
1227
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1228
|
+
const t = data.template;
|
|
1229
|
+
requireDraftStatus(t);
|
|
1230
|
+
const step = findStepById(t.definition, opts.step);
|
|
1231
|
+
if (!step) {
|
|
1232
|
+
console.error(chalk.red(`Step "${opts.step}" not found`));
|
|
1233
|
+
process.exit(1);
|
|
1234
|
+
}
|
|
1235
|
+
if (opts.name) {
|
|
1236
|
+
// Check uniqueness within the step's phase
|
|
1237
|
+
for (const phase of t.definition.phases || []) {
|
|
1238
|
+
if (phase.steps?.find((s) => s.id === step.id)) {
|
|
1239
|
+
if (phase.steps.find((s) => s.id !== step.id && s.name === opts.name)) {
|
|
1240
|
+
console.error(chalk.red(`Step name "${opts.name}" already exists in phase "${phase.name}"`));
|
|
1241
|
+
process.exit(1);
|
|
1242
|
+
}
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
step.name = opts.name;
|
|
1247
|
+
}
|
|
1248
|
+
if (opts.type)
|
|
1249
|
+
step.step_type = opts.type;
|
|
1250
|
+
if (opts.role)
|
|
1251
|
+
step.role = opts.role;
|
|
1252
|
+
if (opts.description != null)
|
|
1253
|
+
step.description = opts.description;
|
|
1254
|
+
if (opts.reviewer != null)
|
|
1255
|
+
step.reviewer = opts.reviewer || null;
|
|
1256
|
+
if (opts.maxReviewCycles != null)
|
|
1257
|
+
step.max_review_cycles = parseInt(opts.maxReviewCycles) || 0;
|
|
1258
|
+
if (opts.parallelGroup != null)
|
|
1259
|
+
step.parallel_group = opts.parallelGroup || null;
|
|
1260
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1261
|
+
console.log(chalk.green(`Updated step: ${step.name}`));
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
console.error(chalk.red(err.message));
|
|
1265
|
+
process.exit(1);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
// ── Template I/O commands ──────────────────────────────────
|
|
1269
|
+
templates
|
|
1270
|
+
.command('steps')
|
|
1271
|
+
.description('List step IDs and names in a template. Use this to discover step IDs needed by --step options in add-output, add-input, remove-step, edit-step, etc.')
|
|
1272
|
+
.argument('<id>', 'Template ID')
|
|
1273
|
+
.action(async (id) => {
|
|
1274
|
+
try {
|
|
1275
|
+
const data = await api('GET', `/api/pipeline-templates/${id}`);
|
|
1276
|
+
const steps = allSteps(data.template.definition);
|
|
1277
|
+
if (!steps.length) {
|
|
1278
|
+
console.log(chalk.yellow('No steps'));
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
for (const s of steps) {
|
|
1282
|
+
console.log(` ${chalk.dim(s.id)} ${s.name} [${s.role || 'unassigned'}]`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
console.error(chalk.red(err.message));
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
templates
|
|
1291
|
+
.command('add-output')
|
|
1292
|
+
.description('Declare what a step produces. Outputs can be consumed by downstream steps via add-input. Template must be in draft status. Output names must be unique within the step.')
|
|
1293
|
+
.argument('<templateId>', 'Template ID')
|
|
1294
|
+
.requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
|
|
1295
|
+
.requiredOption('--name <name>', 'Output name (unique within the step, e.g., "requirements", "metrics_data")')
|
|
1296
|
+
.requiredOption('--type <type>', 'Data type: text, markdown, number, boolean, url, json, object, array')
|
|
1297
|
+
.option('--description <desc>', 'Human-readable description of what this output contains')
|
|
1298
|
+
.option('--optional', 'Mark as not required (default: required)')
|
|
1299
|
+
.option('--format <format>', 'Format hint: file_path, commit_sha, semver, date, datetime, email, integer, percentage')
|
|
1300
|
+
.action(async (templateId, opts) => {
|
|
1301
|
+
try {
|
|
1302
|
+
if (!VALID_IO_TYPES.includes(opts.type)) {
|
|
1303
|
+
console.error(chalk.red(`Invalid type "${opts.type}". Valid: ${VALID_IO_TYPES.join(', ')}`));
|
|
1304
|
+
process.exit(1);
|
|
1305
|
+
}
|
|
1306
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1307
|
+
const t = data.template;
|
|
1308
|
+
requireDraftStatus(t);
|
|
1309
|
+
const step = findStepById(t.definition, opts.step);
|
|
1310
|
+
if (!step) {
|
|
1311
|
+
console.error(chalk.red(`Step "${opts.step}" not found`));
|
|
1312
|
+
process.exit(1);
|
|
1313
|
+
}
|
|
1314
|
+
if (!step.outputs)
|
|
1315
|
+
step.outputs = [];
|
|
1316
|
+
if (step.outputs.find((o) => o.name === opts.name)) {
|
|
1317
|
+
console.error(chalk.red(`Output "${opts.name}" already exists on step "${step.name}"`));
|
|
1318
|
+
process.exit(1);
|
|
1319
|
+
}
|
|
1320
|
+
const output = { name: opts.name, type: opts.type, required: !opts.optional };
|
|
1321
|
+
if (opts.description)
|
|
1322
|
+
output.description = opts.description;
|
|
1323
|
+
if (opts.format)
|
|
1324
|
+
output.format = opts.format;
|
|
1325
|
+
step.outputs.push(output);
|
|
1326
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1327
|
+
console.log(chalk.green(`Added output → ${opts.name} (${opts.type}) to ${step.name}`));
|
|
1328
|
+
}
|
|
1329
|
+
catch (err) {
|
|
1330
|
+
console.error(chalk.red(err.message));
|
|
1331
|
+
process.exit(1);
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
templates
|
|
1335
|
+
.command('remove-output')
|
|
1336
|
+
.description('Remove an output declaration from a step. Blocked if downstream steps consume this output via add-input unless --force is used. Template must be in draft status.')
|
|
1337
|
+
.argument('<templateId>', 'Template ID')
|
|
1338
|
+
.requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
|
|
1339
|
+
.requiredOption('--name <name>', 'Output name to remove')
|
|
1340
|
+
.option('--force', 'Remove even if downstream steps reference this output')
|
|
1341
|
+
.action(async (templateId, opts) => {
|
|
1342
|
+
try {
|
|
1343
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1344
|
+
const t = data.template;
|
|
1345
|
+
requireDraftStatus(t);
|
|
1346
|
+
const step = findStepById(t.definition, opts.step);
|
|
1347
|
+
if (!step) {
|
|
1348
|
+
console.error(chalk.red(`Step "${opts.step}" not found`));
|
|
1349
|
+
process.exit(1);
|
|
1350
|
+
}
|
|
1351
|
+
const idx = (step.outputs || []).findIndex((o) => o.name === opts.name);
|
|
1352
|
+
if (idx === -1) {
|
|
1353
|
+
console.error(chalk.red(`Output "${opts.name}" not found on step "${step.name}"`));
|
|
1354
|
+
process.exit(1);
|
|
1355
|
+
}
|
|
1356
|
+
// Check for downstream references
|
|
1357
|
+
const refs = allSteps(t.definition).filter((s) => (s.inputs || []).some((inp) => inp.source_step_id === step.id && inp.source_output_name === opts.name));
|
|
1358
|
+
if (refs.length && !opts.force) {
|
|
1359
|
+
console.error(chalk.yellow(`Output "${opts.name}" is referenced by: ${refs.map((r) => r.name).join(', ')}`));
|
|
1360
|
+
console.error(chalk.yellow('Use --force to remove anyway'));
|
|
1361
|
+
process.exit(1);
|
|
1362
|
+
}
|
|
1363
|
+
step.outputs.splice(idx, 1);
|
|
1364
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1365
|
+
console.log(chalk.green(`Removed output → ${opts.name} from ${step.name}`));
|
|
1366
|
+
}
|
|
1367
|
+
catch (err) {
|
|
1368
|
+
console.error(chalk.red(err.message));
|
|
1369
|
+
process.exit(1);
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
templates
|
|
1373
|
+
.command('add-input')
|
|
1374
|
+
.description('Declare what a step consumes. Two modes (mutually exclusive):\n' +
|
|
1375
|
+
' Step-sourced: --source-step + --source-output (consumes output from another step)\n' +
|
|
1376
|
+
' Pipeline-sourced: --pipeline-input (consumes a template-level input)\n' +
|
|
1377
|
+
'Validates that the source step/output/pipeline-input exists. Template must be in draft status.')
|
|
1378
|
+
.argument('<templateId>', 'Template ID')
|
|
1379
|
+
.requiredOption('--step <stepId>', 'Step ID receiving the input (from "tpl steps" output)')
|
|
1380
|
+
.requiredOption('--name <name>', 'Local input name (unique within the step)')
|
|
1381
|
+
.option('--source-step <stepId>', 'Upstream step ID that produces the data (use with --source-output)')
|
|
1382
|
+
.option('--source-output <name>', 'Output name on the source step (use with --source-step)')
|
|
1383
|
+
.option('--pipeline-input <name>', 'Pipeline-level input name (use instead of --source-step/--source-output)')
|
|
1384
|
+
.option('--description <desc>', 'What this data is used for')
|
|
1385
|
+
.option('--optional', 'Mark as not required (default: required)')
|
|
1386
|
+
.action(async (templateId, opts) => {
|
|
1387
|
+
try {
|
|
1388
|
+
const hasSrc = opts.sourceStep || opts.sourceOutput;
|
|
1389
|
+
const hasPipeline = opts.pipelineInput;
|
|
1390
|
+
if (!hasSrc && !hasPipeline) {
|
|
1391
|
+
console.error(chalk.red('Provide either --source-step + --source-output or --pipeline-input'));
|
|
1392
|
+
process.exit(1);
|
|
1393
|
+
}
|
|
1394
|
+
if (hasSrc && hasPipeline) {
|
|
1395
|
+
console.error(chalk.red('Cannot use both --source-step and --pipeline-input'));
|
|
1396
|
+
process.exit(1);
|
|
1397
|
+
}
|
|
1398
|
+
if (hasSrc && (!opts.sourceStep || !opts.sourceOutput)) {
|
|
1399
|
+
console.error(chalk.red('Both --source-step and --source-output are required for step-sourced inputs'));
|
|
1400
|
+
process.exit(1);
|
|
1401
|
+
}
|
|
1402
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1403
|
+
const t = data.template;
|
|
1404
|
+
requireDraftStatus(t);
|
|
1405
|
+
const step = findStepById(t.definition, opts.step);
|
|
1406
|
+
if (!step) {
|
|
1407
|
+
console.error(chalk.red(`Step "${opts.step}" not found`));
|
|
1408
|
+
process.exit(1);
|
|
1409
|
+
}
|
|
1410
|
+
if (!step.inputs)
|
|
1411
|
+
step.inputs = [];
|
|
1412
|
+
if (step.inputs.find((i) => i.name === opts.name)) {
|
|
1413
|
+
console.error(chalk.red(`Input "${opts.name}" already exists on step "${step.name}"`));
|
|
1414
|
+
process.exit(1);
|
|
1415
|
+
}
|
|
1416
|
+
// Validate references
|
|
1417
|
+
if (opts.sourceStep) {
|
|
1418
|
+
if (opts.sourceStep === step.id) {
|
|
1419
|
+
console.error(chalk.red('Cannot reference own step (self-reference)'));
|
|
1420
|
+
process.exit(1);
|
|
1421
|
+
}
|
|
1422
|
+
const srcStep = findStepById(t.definition, opts.sourceStep);
|
|
1423
|
+
if (!srcStep) {
|
|
1424
|
+
console.error(chalk.red(`Source step "${opts.sourceStep}" not found`));
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
if (!(srcStep.outputs || []).find((o) => o.name === opts.sourceOutput)) {
|
|
1428
|
+
console.error(chalk.red(`Output "${opts.sourceOutput}" not declared on step "${srcStep.name}"`));
|
|
1429
|
+
process.exit(1);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (opts.pipelineInput) {
|
|
1433
|
+
const pInputs = t.definition.inputs || [];
|
|
1434
|
+
if (!pInputs.find((i) => i.name === opts.pipelineInput)) {
|
|
1435
|
+
console.error(chalk.red(`Pipeline input "${opts.pipelineInput}" not found. Add it first with tpl add-pipeline-input`));
|
|
1436
|
+
process.exit(1);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
const input = { name: opts.name, required: !opts.optional };
|
|
1440
|
+
if (opts.sourceStep) {
|
|
1441
|
+
input.source_step_id = opts.sourceStep;
|
|
1442
|
+
input.source_output_name = opts.sourceOutput;
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
input.pipeline_input = opts.pipelineInput;
|
|
1446
|
+
}
|
|
1447
|
+
if (opts.description)
|
|
1448
|
+
input.description = opts.description;
|
|
1449
|
+
step.inputs.push(input);
|
|
1450
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1451
|
+
if (opts.sourceStep) {
|
|
1452
|
+
const srcStep = findStepById(t.definition, opts.sourceStep);
|
|
1453
|
+
console.log(chalk.green(`Added input ← ${opts.name} ← ${srcStep.name}.${opts.sourceOutput} to ${step.name}`));
|
|
1454
|
+
}
|
|
1455
|
+
else {
|
|
1456
|
+
console.log(chalk.green(`Added input ← ${opts.name} ← pipeline.${opts.pipelineInput} to ${step.name}`));
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
catch (err) {
|
|
1460
|
+
console.error(chalk.red(err.message));
|
|
1461
|
+
process.exit(1);
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
templates
|
|
1465
|
+
.command('remove-input')
|
|
1466
|
+
.description('Remove an input declaration from a step. Template must be in draft status.')
|
|
1467
|
+
.argument('<templateId>', 'Template ID')
|
|
1468
|
+
.requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
|
|
1469
|
+
.requiredOption('--name <name>', 'Input name to remove')
|
|
1470
|
+
.action(async (templateId, opts) => {
|
|
1471
|
+
try {
|
|
1472
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1473
|
+
const t = data.template;
|
|
1474
|
+
requireDraftStatus(t);
|
|
1475
|
+
const step = findStepById(t.definition, opts.step);
|
|
1476
|
+
if (!step) {
|
|
1477
|
+
console.error(chalk.red(`Step "${opts.step}" not found`));
|
|
1478
|
+
process.exit(1);
|
|
1479
|
+
}
|
|
1480
|
+
const idx = (step.inputs || []).findIndex((i) => i.name === opts.name);
|
|
1481
|
+
if (idx === -1) {
|
|
1482
|
+
console.error(chalk.red(`Input "${opts.name}" not found on step "${step.name}"`));
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
step.inputs.splice(idx, 1);
|
|
1486
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1487
|
+
console.log(chalk.green(`Removed input ← ${opts.name} from ${step.name}`));
|
|
1488
|
+
}
|
|
1489
|
+
catch (err) {
|
|
1490
|
+
console.error(chalk.red(err.message));
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
templates
|
|
1495
|
+
.command('add-pipeline-input')
|
|
1496
|
+
.description('Add a template-level input that is provided when creating a pipeline instance. Steps can consume these via "tpl add-input --pipeline-input <name>". Template must be in draft status.')
|
|
1497
|
+
.argument('<templateId>', 'Template ID')
|
|
1498
|
+
.requiredOption('--name <name>', 'Input name (unique within the template)')
|
|
1499
|
+
.requiredOption('--type <type>', 'Data type: text, markdown, number, boolean, url, json, object, array')
|
|
1500
|
+
.option('--description <desc>', 'Human-readable description')
|
|
1501
|
+
.option('--optional', 'Not required at instance creation (default: required)')
|
|
1502
|
+
.option('--default <value>', 'Default value')
|
|
1503
|
+
.action(async (templateId, opts) => {
|
|
1504
|
+
try {
|
|
1505
|
+
if (!VALID_IO_TYPES.includes(opts.type)) {
|
|
1506
|
+
console.error(chalk.red(`Invalid type "${opts.type}". Valid: ${VALID_IO_TYPES.join(', ')}`));
|
|
1507
|
+
process.exit(1);
|
|
1508
|
+
}
|
|
1509
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1510
|
+
const t = data.template;
|
|
1511
|
+
requireDraftStatus(t);
|
|
1512
|
+
if (!t.definition.inputs)
|
|
1513
|
+
t.definition.inputs = [];
|
|
1514
|
+
if (t.definition.inputs.find((i) => i.name === opts.name)) {
|
|
1515
|
+
console.error(chalk.red(`Pipeline input "${opts.name}" already exists`));
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
}
|
|
1518
|
+
const input = { name: opts.name, type: opts.type, required: !opts.optional };
|
|
1519
|
+
if (opts.description)
|
|
1520
|
+
input.description = opts.description;
|
|
1521
|
+
if (opts.default != null)
|
|
1522
|
+
input.default_value = opts.default;
|
|
1523
|
+
t.definition.inputs.push(input);
|
|
1524
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1525
|
+
console.log(chalk.green(`Added pipeline input: ${opts.name} (${opts.type})`));
|
|
1526
|
+
}
|
|
1527
|
+
catch (err) {
|
|
1528
|
+
console.error(chalk.red(err.message));
|
|
1529
|
+
process.exit(1);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
templates
|
|
1533
|
+
.command('remove-pipeline-input')
|
|
1534
|
+
.description('Remove a template-level input. Blocked if steps reference this input via --pipeline-input unless --force is used. Template must be in draft status.')
|
|
1535
|
+
.argument('<templateId>', 'Template ID')
|
|
1536
|
+
.requiredOption('--name <name>', 'Pipeline input name to remove')
|
|
1537
|
+
.option('--force', 'Remove even if steps reference this input')
|
|
1538
|
+
.action(async (templateId, opts) => {
|
|
1539
|
+
try {
|
|
1540
|
+
const data = await api('GET', `/api/pipeline-templates/${templateId}`);
|
|
1541
|
+
const t = data.template;
|
|
1542
|
+
requireDraftStatus(t);
|
|
1543
|
+
const inputs = t.definition.inputs || [];
|
|
1544
|
+
const idx = inputs.findIndex((i) => i.name === opts.name);
|
|
1545
|
+
if (idx === -1) {
|
|
1546
|
+
console.error(chalk.red(`Pipeline input "${opts.name}" not found`));
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
// Block if steps reference it unless --force
|
|
1550
|
+
const refs = allSteps(t.definition).filter((s) => (s.inputs || []).some((inp) => inp.pipeline_input === opts.name));
|
|
1551
|
+
if (refs.length && !opts.force) {
|
|
1552
|
+
console.error(chalk.yellow(`Pipeline input "${opts.name}" is referenced by: ${refs.map((r) => r.name).join(', ')}`));
|
|
1553
|
+
console.error(chalk.yellow('Use --force to remove anyway'));
|
|
1554
|
+
process.exit(1);
|
|
1555
|
+
}
|
|
1556
|
+
inputs.splice(idx, 1);
|
|
1557
|
+
await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
|
|
1558
|
+
console.log(chalk.green(`Removed pipeline input: ${opts.name}`));
|
|
1559
|
+
}
|
|
1560
|
+
catch (err) {
|
|
1561
|
+
console.error(chalk.red(err.message));
|
|
1562
|
+
process.exit(1);
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
templates
|
|
1566
|
+
.command('validate')
|
|
1567
|
+
.description('Validate template I/O contracts. Checks for: circular dependencies between steps, missing source step/output references, self-references, duplicate input names per step. Also reports warnings for unreferenced outputs and info for steps with no outputs declared. Run before activating to catch errors early.')
|
|
1568
|
+
.argument('<id>', 'Template ID')
|
|
1569
|
+
.action(async (id) => {
|
|
1570
|
+
try {
|
|
1571
|
+
const data = await api('GET', `/api/pipeline-templates/${id}`);
|
|
1572
|
+
const t = data.template;
|
|
1573
|
+
const steps = allSteps(t.definition);
|
|
1574
|
+
const pipelineInputs = t.definition.inputs || [];
|
|
1575
|
+
console.log(`Validating: ${chalk.cyan(t.name)} (${id})\n`);
|
|
1576
|
+
let passed = 0, warnings = 0, infos = 0, errors = 0;
|
|
1577
|
+
// Check for duplicate input names per step
|
|
1578
|
+
let hasDupes = false;
|
|
1579
|
+
for (const step of steps) {
|
|
1580
|
+
const names = (step.inputs || []).map((i) => i.name);
|
|
1581
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
1582
|
+
if (dupes.length) {
|
|
1583
|
+
console.log(chalk.red(` ✗ Step "${step.name}" has duplicate input names: ${[...new Set(dupes)].join(', ')}`));
|
|
1584
|
+
hasDupes = true;
|
|
1585
|
+
errors++;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
if (!hasDupes) {
|
|
1589
|
+
console.log(chalk.green(' ✓ No duplicate input names'));
|
|
1590
|
+
passed++;
|
|
1591
|
+
}
|
|
1592
|
+
// Check self-references
|
|
1593
|
+
let hasSelfRef = false;
|
|
1594
|
+
for (const step of steps) {
|
|
1595
|
+
for (const inp of step.inputs || []) {
|
|
1596
|
+
if (inp.source_step_id === step.id) {
|
|
1597
|
+
console.log(chalk.red(` ✗ Step "${step.name}" input "${inp.name}" references itself`));
|
|
1598
|
+
hasSelfRef = true;
|
|
1599
|
+
errors++;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (!hasSelfRef) {
|
|
1604
|
+
console.log(chalk.green(' ✓ No self-references'));
|
|
1605
|
+
passed++;
|
|
1606
|
+
}
|
|
1607
|
+
// Check all input references resolve
|
|
1608
|
+
let hasUnresolved = false;
|
|
1609
|
+
for (const step of steps) {
|
|
1610
|
+
for (const inp of step.inputs || []) {
|
|
1611
|
+
if (inp.source_step_id) {
|
|
1612
|
+
const src = findStepById(t.definition, inp.source_step_id);
|
|
1613
|
+
if (!src) {
|
|
1614
|
+
console.log(chalk.red(` ✗ Step "${step.name}" input "${inp.name}" references missing step "${inp.source_step_id}"`));
|
|
1615
|
+
hasUnresolved = true;
|
|
1616
|
+
errors++;
|
|
1617
|
+
}
|
|
1618
|
+
else if (!(src.outputs || []).find((o) => o.name === inp.source_output_name)) {
|
|
1619
|
+
console.log(chalk.red(` ✗ Step "${step.name}" input "${inp.name}" references missing output "${inp.source_output_name}" on step "${src.name}"`));
|
|
1620
|
+
hasUnresolved = true;
|
|
1621
|
+
errors++;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (inp.pipeline_input) {
|
|
1625
|
+
if (!pipelineInputs.find((p) => p.name === inp.pipeline_input)) {
|
|
1626
|
+
console.log(chalk.red(` ✗ Step "${step.name}" input "${inp.name}" references missing pipeline input "${inp.pipeline_input}"`));
|
|
1627
|
+
hasUnresolved = true;
|
|
1628
|
+
errors++;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (!hasUnresolved) {
|
|
1634
|
+
console.log(chalk.green(' ✓ All input references resolve'));
|
|
1635
|
+
passed++;
|
|
1636
|
+
}
|
|
1637
|
+
// Cycle detection via topological sort (Kahn's algorithm)
|
|
1638
|
+
const stepIds = steps.map((s) => s.id);
|
|
1639
|
+
const adj = new Map();
|
|
1640
|
+
const inDeg = new Map();
|
|
1641
|
+
for (const sid of stepIds) {
|
|
1642
|
+
adj.set(sid, []);
|
|
1643
|
+
inDeg.set(sid, 0);
|
|
1644
|
+
}
|
|
1645
|
+
for (const step of steps) {
|
|
1646
|
+
for (const inp of step.inputs || []) {
|
|
1647
|
+
if (inp.source_step_id && stepIds.includes(inp.source_step_id)) {
|
|
1648
|
+
adj.get(inp.source_step_id).push(step.id);
|
|
1649
|
+
inDeg.set(step.id, (inDeg.get(step.id) || 0) + 1);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
const queue = [];
|
|
1654
|
+
for (const [id, deg] of inDeg) {
|
|
1655
|
+
if (deg === 0)
|
|
1656
|
+
queue.push(id);
|
|
1657
|
+
}
|
|
1658
|
+
let visited = 0;
|
|
1659
|
+
while (queue.length) {
|
|
1660
|
+
const cur = queue.shift();
|
|
1661
|
+
visited++;
|
|
1662
|
+
for (const next of adj.get(cur) || []) {
|
|
1663
|
+
const nd = inDeg.get(next) - 1;
|
|
1664
|
+
inDeg.set(next, nd);
|
|
1665
|
+
if (nd === 0)
|
|
1666
|
+
queue.push(next);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (visited < stepIds.length) {
|
|
1670
|
+
console.log(chalk.red(' ✗ Cycle detected in step dependencies'));
|
|
1671
|
+
errors++;
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
console.log(chalk.green(' ✓ No cycles detected'));
|
|
1675
|
+
passed++;
|
|
1676
|
+
}
|
|
1677
|
+
// Unreferenced outputs (warning)
|
|
1678
|
+
for (const step of steps) {
|
|
1679
|
+
for (const output of step.outputs || []) {
|
|
1680
|
+
const referenced = steps.some((s) => (s.inputs || []).some((inp) => inp.source_step_id === step.id && inp.source_output_name === output.name));
|
|
1681
|
+
if (!referenced) {
|
|
1682
|
+
console.log(chalk.yellow(` ⚠ Step "${step.name}" output "${output.name}" is not consumed by any downstream step`));
|
|
1683
|
+
warnings++;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
// Steps with no outputs (info)
|
|
1688
|
+
for (const step of steps) {
|
|
1689
|
+
if (!step.outputs?.length) {
|
|
1690
|
+
console.log(chalk.dim(` ℹ Step "${step.name}" has no outputs declared`));
|
|
1691
|
+
infos++;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
console.log(`\n${passed} check${passed !== 1 ? 's' : ''} passed${errors ? `, ${errors} error${errors !== 1 ? 's' : ''}` : ''}${warnings ? `, ${warnings} warning${warnings !== 1 ? 's' : ''}` : ''}${infos ? `, ${infos} info` : ''}`);
|
|
1695
|
+
if (errors)
|
|
1696
|
+
process.exit(1);
|
|
1697
|
+
}
|
|
1698
|
+
catch (err) {
|
|
1699
|
+
console.error(chalk.red(err.message));
|
|
1700
|
+
process.exit(1);
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
838
1703
|
// ── Pipelines ──────────────────────────────────────────────
|
|
839
1704
|
const pipelines = program.command('pipelines').alias('pl').description('Pipeline instance commands');
|
|
840
1705
|
pipelines
|