@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 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} (${step.stepType || 'human'}) — role: ${step.role || 'unassigned'}`);
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 a pipeline template')
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 steps = raw.steps || [];
782
- // Group sequential steps into a single phase for now
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: steps.map((s, i) => ({
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
- outputs: [],
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} (${steps.length} steps)`));
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