@4ge/cli 0.1.0 β 0.2.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/commands/flow/index.js +10 -0
- package/dist/commands/flow/list.js +85 -0
- package/dist/commands/flow/pull.js +109 -0
- package/dist/commands/flow/show.js +58 -0
- package/dist/commands/flow/update.js +105 -0
- package/dist/commands/template/list.js +3 -1
- package/dist/generated/api/index.js +1 -1
- package/dist/generated/api/sdk.gen.js +56 -0
- package/dist/generated/api/zod.gen.js +167 -0
- package/dist/ui/components/cli-header.js +9 -6
- package/dist/ui/components/data-table.js +6 -1
- package/package.json +2 -1
- package/skills/4ge-cli.md +71 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Help } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
export default class FlowIndex extends BaseCommand {
|
|
4
|
+
static description = 'Manage feature flows in your project';
|
|
5
|
+
async run() {
|
|
6
|
+
await this.renderHeader('ready', 'compact');
|
|
7
|
+
const help = new Help(this.config);
|
|
8
|
+
await help.showHelp(['flow']);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import { BaseCommand } from '../../base.js';
|
|
4
|
+
import { listFlows } from '../../generated/api/sdk.gen.js';
|
|
5
|
+
import { zListFlowsResponse } from '../../generated/api/zod.gen.js';
|
|
6
|
+
import { validateResponse } from '../../core/api/index.js';
|
|
7
|
+
import { DataTable } from '../../ui/index.js';
|
|
8
|
+
export default class FlowList extends BaseCommand {
|
|
9
|
+
static description = 'List flows for a feature in the current project';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> flow:list --feature-id <uuid>',
|
|
12
|
+
'<%= config.bin %> flow:list --feature-id <uuid> --roadmap now --json',
|
|
13
|
+
'<%= config.bin %> flow:list --feature-id <uuid> --status IN_DEVELOPMENT',
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
...BaseCommand.baseFlags,
|
|
17
|
+
'feature-id': Flags.string({
|
|
18
|
+
description: 'Feature ID whose flows to list (a feature owns one implied flow)',
|
|
19
|
+
required: true,
|
|
20
|
+
}),
|
|
21
|
+
roadmap: Flags.string({
|
|
22
|
+
description: 'Filter by roadmap status (now|next|later)',
|
|
23
|
+
options: ['now', 'next', 'later'],
|
|
24
|
+
}),
|
|
25
|
+
status: Flags.string({
|
|
26
|
+
description: 'Filter by status (e.g. BACKLOG, IN_DEVELOPMENT, COMPLETE)',
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
async run() {
|
|
30
|
+
const { flags } = await this.parse(FlowList);
|
|
31
|
+
await this.renderHeader('ready', 'compact');
|
|
32
|
+
const client = await this.getClient();
|
|
33
|
+
const projectId = await this.getProjectIdOrExit();
|
|
34
|
+
try {
|
|
35
|
+
const response = await this.withSpinner('Fetching flows...', () => listFlows({
|
|
36
|
+
client,
|
|
37
|
+
path: {
|
|
38
|
+
projectId,
|
|
39
|
+
featureId: flags['feature-id'],
|
|
40
|
+
},
|
|
41
|
+
query: {
|
|
42
|
+
...(flags.roadmap ? { roadmap: flags.roadmap } : {}),
|
|
43
|
+
...(flags.status ? { status: flags.status } : {}),
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
const { data, error } = response;
|
|
47
|
+
const validated = validateResponse(data, error, zListFlowsResponse);
|
|
48
|
+
const items = validated.data;
|
|
49
|
+
const metadata = validated.metadata;
|
|
50
|
+
if (this.isJsonMode) {
|
|
51
|
+
this.outputJson(items, { source: 'flow list' });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
await this.renderInk(_jsx(DataTable, { data: items, columns: [
|
|
55
|
+
{ key: 'label', header: 'Title', minWidth: 20 },
|
|
56
|
+
{ key: 'id', header: 'ID' },
|
|
57
|
+
{ key: 'status', header: 'Status', color: 'auto' },
|
|
58
|
+
{ key: 'roadmap_status', header: 'Roadmap' },
|
|
59
|
+
{ key: 'step_count', header: 'Steps' },
|
|
60
|
+
{ key: 'parent_feature_id', header: 'Feature ID' },
|
|
61
|
+
], title: `Flows in feature ${flags['feature-id']}${flags.roadmap ? ` (${flags.roadmap})` : ''}`, emptyMessage: `No flows found${flags.roadmap ? ` for roadmap: ${flags.roadmap}` : ''}` }));
|
|
62
|
+
if (this.isInteractiveMode) {
|
|
63
|
+
if (items.length > 0) {
|
|
64
|
+
const options = items.map((item) => ({
|
|
65
|
+
label: item.label || item.id,
|
|
66
|
+
value: item.id,
|
|
67
|
+
description: item.status || '',
|
|
68
|
+
}));
|
|
69
|
+
const selectedId = await this.selectOption('Select a flow for details:', options, { headerVariant: 'compact' });
|
|
70
|
+
if (selectedId) {
|
|
71
|
+
const { default: chalk } = await import('chalk');
|
|
72
|
+
this.log(`\n π Run: ${chalk.cyan(`4ge flow show ${selectedId}`)}\n`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (metadata?.action_hints) {
|
|
76
|
+
await this.handleActionHints(metadata.action_hints);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
this.handleError('An unexpected error occurred', err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import { BaseCommand } from '../../base.js';
|
|
4
|
+
import { getCollectionDir } from '../../core/config/index.js';
|
|
5
|
+
import { resolveRecursive } from '../../core/config/defaults.js';
|
|
6
|
+
import { getFlow, getFlowTree } from '../../generated/api/sdk.gen.js';
|
|
7
|
+
import { zGetFlowTreeResponse, zGetFlowResponse } from '../../generated/api/zod.gen.js';
|
|
8
|
+
import { validateResponse } from '../../core/api/index.js';
|
|
9
|
+
import { SuccessMessage } from '../../ui/index.js';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
export default class FlowPull extends BaseCommand {
|
|
13
|
+
static description = 'Download flow details';
|
|
14
|
+
static examples = [
|
|
15
|
+
'<%= config.bin %> flow:pull --id <uuid>',
|
|
16
|
+
'<%= config.bin %> flow:pull --id <uuid> --json',
|
|
17
|
+
'<%= config.bin %> flow:pull --id <uuid> --recursive',
|
|
18
|
+
];
|
|
19
|
+
static flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
id: Flags.string({
|
|
22
|
+
description: 'Flow ID',
|
|
23
|
+
required: true,
|
|
24
|
+
}),
|
|
25
|
+
recursive: Flags.boolean({
|
|
26
|
+
description: 'Pull full flow tree (flow + step nodes) instead of just the flow detail',
|
|
27
|
+
allowNo: true,
|
|
28
|
+
}),
|
|
29
|
+
save: Flags.boolean({
|
|
30
|
+
description: 'Force save to disk even in JSON mode',
|
|
31
|
+
default: false,
|
|
32
|
+
}),
|
|
33
|
+
stdout: Flags.boolean({
|
|
34
|
+
description: 'Print to stdout without saving',
|
|
35
|
+
default: false,
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
async run() {
|
|
39
|
+
const { flags } = await this.parse(FlowPull);
|
|
40
|
+
if (flags.json && flags.stdout) {
|
|
41
|
+
this.error('Cannot use --json and --stdout together');
|
|
42
|
+
}
|
|
43
|
+
if (flags.save && flags.stdout) {
|
|
44
|
+
this.error('Cannot use --stdout and --save together');
|
|
45
|
+
}
|
|
46
|
+
await this.renderHeader('ready', 'compact');
|
|
47
|
+
await this.migrateAssetsIfNeeded();
|
|
48
|
+
const client = await this.getClient();
|
|
49
|
+
const projectId = await this.getProjectIdOrExit();
|
|
50
|
+
const isRecursive = resolveRecursive(flags.recursive, this.localConfig);
|
|
51
|
+
const spinnerMsg = isRecursive ? `Pulling flow ${flags.id} tree...` : `Pulling flow ${flags.id}...`;
|
|
52
|
+
const response = await this.withSpinner(spinnerMsg, () => {
|
|
53
|
+
if (isRecursive) {
|
|
54
|
+
return getFlowTree({
|
|
55
|
+
client,
|
|
56
|
+
path: {
|
|
57
|
+
projectId,
|
|
58
|
+
flowId: flags.id,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
return getFlow({
|
|
64
|
+
client,
|
|
65
|
+
path: {
|
|
66
|
+
projectId,
|
|
67
|
+
flowId: flags.id,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const { data, error } = response;
|
|
73
|
+
try {
|
|
74
|
+
let flow;
|
|
75
|
+
let metadata;
|
|
76
|
+
if (isRecursive) {
|
|
77
|
+
const validated = validateResponse(data, error, zGetFlowTreeResponse);
|
|
78
|
+
flow = validated.data;
|
|
79
|
+
metadata = validated.metadata;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const validated = validateResponse(data, error, zGetFlowResponse);
|
|
83
|
+
flow = validated.data;
|
|
84
|
+
metadata = validated.metadata;
|
|
85
|
+
}
|
|
86
|
+
const savePath = path.join(getCollectionDir('flows'), `${flags.id}.json`);
|
|
87
|
+
if (this.isJsonMode) {
|
|
88
|
+
this.outputJson(flow);
|
|
89
|
+
if (flags.save) {
|
|
90
|
+
await this.saveToFile(savePath, flow);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (flags.stdout) {
|
|
94
|
+
this.log(JSON.stringify(flow, null, 2));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await this.saveToFile(savePath, flow);
|
|
98
|
+
await this.renderInk(_jsx(SuccessMessage, { message: `Flow saved to .4ge/assets/flows/${flags.id}.json` }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
this.handleError(`Failed to pull flow: ${flags.id}`, err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async saveToFile(filePath, data) {
|
|
106
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
107
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
import { getFlow } from '../../generated/api/sdk.gen.js';
|
|
4
|
+
import { zGetFlowResponse } from '../../generated/api/zod.gen.js';
|
|
5
|
+
import { validateResponse } from '../../core/api/index.js';
|
|
6
|
+
export default class FlowShow extends BaseCommand {
|
|
7
|
+
static description = 'Show detailed information for a specific flow';
|
|
8
|
+
static examples = [
|
|
9
|
+
'<%= config.bin %> flow:show --id <uuid>',
|
|
10
|
+
'<%= config.bin %> flow:show --id <uuid> --json',
|
|
11
|
+
];
|
|
12
|
+
static flags = {
|
|
13
|
+
...BaseCommand.baseFlags,
|
|
14
|
+
id: Flags.string({
|
|
15
|
+
description: 'Flow ID',
|
|
16
|
+
required: true,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
async run() {
|
|
20
|
+
const { flags } = await this.parse(FlowShow);
|
|
21
|
+
const client = await this.getClient();
|
|
22
|
+
const projectId = await this.getProjectIdOrExit();
|
|
23
|
+
await this.renderHeader('ready', 'compact');
|
|
24
|
+
const response = await this.withSpinner(`Fetching flow ${flags.id}...`, () => getFlow({
|
|
25
|
+
client,
|
|
26
|
+
path: {
|
|
27
|
+
projectId,
|
|
28
|
+
flowId: flags.id,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
const { data, error } = response;
|
|
32
|
+
try {
|
|
33
|
+
const validated = validateResponse(data, error, zGetFlowResponse);
|
|
34
|
+
const payload = validated.data;
|
|
35
|
+
const metadata = validated.metadata;
|
|
36
|
+
if (this.isJsonMode) {
|
|
37
|
+
this.outputJson(payload, { source: 'flow show' });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
this.log(`\nFlow: ${payload.label}`);
|
|
41
|
+
this.log(`ID: ${payload.id}`);
|
|
42
|
+
this.log(`Status: ${payload.status}`);
|
|
43
|
+
this.log(`Roadmap: ${payload.roadmap_status ?? 'None'}`);
|
|
44
|
+
this.log(`Feature ID: ${payload.parent_feature_id ?? payload.parent_id ?? 'None'}`);
|
|
45
|
+
this.log(`Steps: ${payload.step_count}`);
|
|
46
|
+
if (payload.description) {
|
|
47
|
+
this.log(`\nDescription:\n${payload.description}\n`);
|
|
48
|
+
}
|
|
49
|
+
if (this.isInteractiveMode && metadata?.action_hints) {
|
|
50
|
+
await this.handleActionHints(metadata.action_hints);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
this.handleError(`Failed to fetch flow detail for ID: ${flags.id}`, err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import { BaseCommand } from '../../base.js';
|
|
4
|
+
import { updateFlow } from '../../generated/api/sdk.gen.js';
|
|
5
|
+
import { zUpdateFlowResponse } from '../../generated/api/zod.gen.js';
|
|
6
|
+
import { validateResponse } from '../../core/api/index.js';
|
|
7
|
+
import { SuccessMessage } from '../../ui/index.js';
|
|
8
|
+
export default class FlowUpdate extends BaseCommand {
|
|
9
|
+
static description = 'Update a flow';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> flow:update --id <uuid> --status IN_DEVELOPMENT',
|
|
12
|
+
'<%= config.bin %> flow:update --id <uuid> --status COMPLETE --title "New Title"',
|
|
13
|
+
'<%= config.bin %> flow:update --id <uuid> --roadmap-status next --priority MUST_HAVE',
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
...BaseCommand.baseFlags,
|
|
17
|
+
id: Flags.string({
|
|
18
|
+
description: 'Flow ID (UUID)',
|
|
19
|
+
required: true,
|
|
20
|
+
}),
|
|
21
|
+
status: Flags.string({
|
|
22
|
+
description: 'New status (BACKLOG|NOT_STARTED|IN_PLANNING|READY_FOR_DEVELOPMENT|IN_DEVELOPMENT|BLOCKED|REVIEW|COMPLETE)',
|
|
23
|
+
options: ['BACKLOG', 'NOT_STARTED', 'IN_PLANNING', 'READY_FOR_DEVELOPMENT', 'IN_DEVELOPMENT', 'BLOCKED', 'REVIEW', 'COMPLETE'],
|
|
24
|
+
}),
|
|
25
|
+
title: Flags.string({
|
|
26
|
+
description: 'New title',
|
|
27
|
+
}),
|
|
28
|
+
description: Flags.string({
|
|
29
|
+
description: 'New description',
|
|
30
|
+
}),
|
|
31
|
+
priority: Flags.string({
|
|
32
|
+
description: 'New priority (MUST_HAVE|SHOULD_HAVE|BACKLOG)',
|
|
33
|
+
options: ['MUST_HAVE', 'SHOULD_HAVE', 'BACKLOG'],
|
|
34
|
+
}),
|
|
35
|
+
'roadmap-status': Flags.string({
|
|
36
|
+
description: 'New roadmap status (now|next|later)',
|
|
37
|
+
options: ['now', 'next', 'later'],
|
|
38
|
+
}),
|
|
39
|
+
force: Flags.boolean({
|
|
40
|
+
description: 'Skip confirmation prompt',
|
|
41
|
+
default: false,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
async run() {
|
|
45
|
+
const { flags } = await this.parse(FlowUpdate);
|
|
46
|
+
const projectId = await this.getProjectIdOrExit();
|
|
47
|
+
await this.renderHeader('ready', 'compact');
|
|
48
|
+
const body = {};
|
|
49
|
+
if (flags.status !== undefined)
|
|
50
|
+
body.status = flags.status;
|
|
51
|
+
if (flags.title !== undefined)
|
|
52
|
+
body.label = flags.title;
|
|
53
|
+
if (flags.description !== undefined)
|
|
54
|
+
body.description = flags.description;
|
|
55
|
+
if (flags.priority !== undefined)
|
|
56
|
+
body.priority = flags.priority;
|
|
57
|
+
if (flags['roadmap-status'] !== undefined)
|
|
58
|
+
body.roadmap_status = flags['roadmap-status'];
|
|
59
|
+
if (Object.keys(body).length === 0) {
|
|
60
|
+
const message = 'At least one field must be provided to update (--status, --title, --description, --priority, --roadmap-status)';
|
|
61
|
+
if (this.isJsonMode) {
|
|
62
|
+
this.outputErrorAndExit({ message, code: 'MISSING_INPUT' });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
this.error(message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const confirmed = await this.confirmAction(`Update flow ${flags.id}?`, flags.force, { headerVariant: 'compact' });
|
|
69
|
+
if (!confirmed)
|
|
70
|
+
return;
|
|
71
|
+
const payload = {
|
|
72
|
+
path: {
|
|
73
|
+
projectId,
|
|
74
|
+
flowId: flags.id,
|
|
75
|
+
},
|
|
76
|
+
body,
|
|
77
|
+
};
|
|
78
|
+
if (this.isDryRun) {
|
|
79
|
+
await this.handleDryRun(payload);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const client = await this.getClient();
|
|
83
|
+
const { data, error } = await this.withSpinner(`Updating flow ${flags.id}...`, () => updateFlow({
|
|
84
|
+
client,
|
|
85
|
+
...payload,
|
|
86
|
+
}));
|
|
87
|
+
try {
|
|
88
|
+
const validated = validateResponse(data, error, zUpdateFlowResponse);
|
|
89
|
+
const flow = validated.data;
|
|
90
|
+
const metadata = validated.metadata;
|
|
91
|
+
if (this.isJsonMode) {
|
|
92
|
+
this.outputJson(flow, { source: 'flow update', ...metadata });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
await this.renderInk(_jsx(SuccessMessage, { message: `Flow ${flags.id} updated successfully!` }));
|
|
96
|
+
if (this.isInteractiveMode && metadata?.action_hints) {
|
|
97
|
+
await this.handleActionHints(metadata.action_hints);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
this.handleError(`Failed to update flow: ${flags.id}`, err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -5,6 +5,8 @@ import { listTemplates } from '../../generated/api/sdk.gen.js';
|
|
|
5
5
|
import { zListTemplatesResponse } from '../../generated/api/zod.gen.js';
|
|
6
6
|
import { validateResponse } from '../../core/api/index.js';
|
|
7
7
|
import { DataTable } from '../../ui/index.js';
|
|
8
|
+
/** Render a boolean as a checkmark or empty string. Pure β extracted from the DataTable column def so it's unit-testable. */
|
|
9
|
+
export const formatCheckmark = (v) => (v ? 'β' : '');
|
|
8
10
|
export default class TemplateList extends BaseCommand {
|
|
9
11
|
static description = 'List all available project templates';
|
|
10
12
|
static flags = {
|
|
@@ -52,7 +54,7 @@ export default class TemplateList extends BaseCommand {
|
|
|
52
54
|
{ key: 'framework', header: 'Framework' },
|
|
53
55
|
{ key: 'author', header: 'Author' },
|
|
54
56
|
{ key: 'featuresCount', header: 'Features' },
|
|
55
|
-
{ key: 'isOfficial', header: 'Official', format:
|
|
57
|
+
{ key: 'isOfficial', header: 'Official', format: formatCheckmark },
|
|
56
58
|
], title: "Available Templates", emptyMessage: flags.workspace ? `No templates found for workspace: ${flags.workspace}` : 'No templates found.' }));
|
|
57
59
|
if (this.isInteractiveMode) {
|
|
58
60
|
if (filteredTemplates.length > 0) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// This file is auto-generated by @hey-api/openapi-ts
|
|
2
|
-
export { cliAuthCallback, cliAuthEmailLogin, cliAuthLogin, cliAuthRefresh, cliAuthStatus, createIdea, createProject, createTemplate, createTemplateFeature, deleteTemplate, deleteTemplateFeature, getEpicDetail, getEpicTree, getFeatureDetail, getFeatureTree, getLatestPlanVersion, getPlanMetadata, getPlanTree, getPlanVersionDetail, getPlanVersions, getProjectDetail, getStoryDetail, getStoryTree, getTemplateDetail, graduateIdea, listEpics, listFeatures, listIdeas, listProjects, listStories, listTemplateFeatures, listTemplates, listWorkspaces, updateEpic, updateFeature, updateIdea, updateProject, updateStory, updateTemplate, updateTemplateFeature, } from "./sdk.gen.js";
|
|
2
|
+
export { cliAuthCallback, cliAuthEmailLogin, cliAuthLogin, cliAuthRefresh, cliAuthStatus, createIdea, createProject, createTemplate, createTemplateFeature, deleteTemplate, deleteTemplateFeature, getEpicDetail, getEpicTree, getFeatureDetail, getFeatureTree, getFlow, getFlowTree, getLatestPlanVersion, getPlanMetadata, getPlanTree, getPlanVersionDetail, getPlanVersions, getProjectDetail, getStoryDetail, getStoryTree, getTemplateDetail, graduateIdea, listEpics, listFeatures, listFlows, listIdeas, listProjects, listStories, listTemplateFeatures, listTemplates, listWorkspaces, updateEpic, updateFeature, updateFlow, updateIdea, updateProject, updateStory, updateTemplate, updateTemplateFeature, } from "./sdk.gen.js";
|
|
@@ -301,6 +301,62 @@ export const getFeatureTree = (options) => (options.client ?? client).get({
|
|
|
301
301
|
url: "/api/cli/v1/projects/{projectId}/features/{featureId}/tree",
|
|
302
302
|
...options,
|
|
303
303
|
});
|
|
304
|
+
/**
|
|
305
|
+
* list-flows
|
|
306
|
+
*
|
|
307
|
+
* List flows under a feature. A feature owns one implied flow (its ordered useraction/systemresponse/conditional steps), so this returns a 1-element array. roadmap/status filters apply at the feature level.
|
|
308
|
+
*/
|
|
309
|
+
export const listFlows = (options) => (options.client ?? client).get({
|
|
310
|
+
security: [
|
|
311
|
+
{ scheme: "bearer", type: "http" },
|
|
312
|
+
{ name: "Authorization", type: "apiKey" },
|
|
313
|
+
],
|
|
314
|
+
url: "/api/cli/v1/projects/{projectId}/features/{featureId}/flows",
|
|
315
|
+
...options,
|
|
316
|
+
});
|
|
317
|
+
/**
|
|
318
|
+
* get-flow
|
|
319
|
+
*
|
|
320
|
+
* Get a flow summary. flowId is the owning feature id (a flow is the implied collection of steps attached to a feature via parent_feature_id).
|
|
321
|
+
*/
|
|
322
|
+
export const getFlow = (options) => (options.client ?? client).get({
|
|
323
|
+
security: [
|
|
324
|
+
{ scheme: "bearer", type: "http" },
|
|
325
|
+
{ name: "Authorization", type: "apiKey" },
|
|
326
|
+
],
|
|
327
|
+
url: "/api/cli/v1/projects/{projectId}/flows/{flowId}",
|
|
328
|
+
...options,
|
|
329
|
+
});
|
|
330
|
+
/**
|
|
331
|
+
* update-flow
|
|
332
|
+
*
|
|
333
|
+
* Update a flow. flowId is the owning feature id; flow-level metadata IS the feature metadata (status/label/description/roadmap/priority). Invalid enums return 422.
|
|
334
|
+
*/
|
|
335
|
+
export const updateFlow = (options) => (options.client ?? client).patch({
|
|
336
|
+
security: [
|
|
337
|
+
{ scheme: "bearer", type: "http" },
|
|
338
|
+
{ name: "Authorization", type: "apiKey" },
|
|
339
|
+
],
|
|
340
|
+
url: "/api/cli/v1/projects/{projectId}/flows/{flowId}",
|
|
341
|
+
...options,
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
...options.headers,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
/**
|
|
348
|
+
* get-flow-tree
|
|
349
|
+
*
|
|
350
|
+
* Get a flow with its ordered step tree. Steps are topologically sorted over project_plan_edges; conditional branches nest via parent_id.
|
|
351
|
+
*/
|
|
352
|
+
export const getFlowTree = (options) => (options.client ?? client).get({
|
|
353
|
+
security: [
|
|
354
|
+
{ scheme: "bearer", type: "http" },
|
|
355
|
+
{ name: "Authorization", type: "apiKey" },
|
|
356
|
+
],
|
|
357
|
+
url: "/api/cli/v1/projects/{projectId}/flows/{flowId}/tree",
|
|
358
|
+
...options,
|
|
359
|
+
});
|
|
304
360
|
/**
|
|
305
361
|
* list-stories
|
|
306
362
|
*
|
|
@@ -748,6 +748,173 @@ export const zGetFeatureTreeResponse = z.object({
|
|
|
748
748
|
action_hints: z.array(z.unknown()).optional(),
|
|
749
749
|
}),
|
|
750
750
|
});
|
|
751
|
+
export const zListFlowsPath = z.object({
|
|
752
|
+
projectId: z.uuid(),
|
|
753
|
+
featureId: z.uuid(),
|
|
754
|
+
});
|
|
755
|
+
export const zListFlowsQuery = z.object({
|
|
756
|
+
roadmap: z.enum(["now", "next", "later"]).optional(),
|
|
757
|
+
status: z.string().optional(),
|
|
758
|
+
});
|
|
759
|
+
/**
|
|
760
|
+
* Successful response
|
|
761
|
+
*/
|
|
762
|
+
export const zListFlowsResponse = z.object({
|
|
763
|
+
data: z.array(z.object({
|
|
764
|
+
id: z.uuid(),
|
|
765
|
+
label: z.string(),
|
|
766
|
+
description: z.string().nullable(),
|
|
767
|
+
node_type: z.enum(["flow"]),
|
|
768
|
+
status: z.enum([
|
|
769
|
+
"BACKLOG",
|
|
770
|
+
"NOT_STARTED",
|
|
771
|
+
"IN_PLANNING",
|
|
772
|
+
"READY_FOR_DEVELOPMENT",
|
|
773
|
+
"IN_DEVELOPMENT",
|
|
774
|
+
"BLOCKED",
|
|
775
|
+
"REVIEW",
|
|
776
|
+
"COMPLETE",
|
|
777
|
+
]),
|
|
778
|
+
priority: z.string().nullable(),
|
|
779
|
+
roadmap_status: z
|
|
780
|
+
.enum(["now", "next", "later", "someday", "released"])
|
|
781
|
+
.optional(),
|
|
782
|
+
parent_id: z.uuid().nullable(),
|
|
783
|
+
parent_feature_id: z.uuid().nullish(),
|
|
784
|
+
project_id: z.uuid(),
|
|
785
|
+
workspace_id: z.uuid(),
|
|
786
|
+
position_x: z.number().optional(),
|
|
787
|
+
position_y: z.number().optional(),
|
|
788
|
+
step_count: z.int().gte(0),
|
|
789
|
+
})),
|
|
790
|
+
error: z.unknown(),
|
|
791
|
+
metadata: z.object({
|
|
792
|
+
timestamp: z.string(),
|
|
793
|
+
request_id: z.string(),
|
|
794
|
+
action_hints: z.array(z.unknown()).optional(),
|
|
795
|
+
}),
|
|
796
|
+
});
|
|
797
|
+
export const zGetFlowPath = z.object({
|
|
798
|
+
projectId: z.uuid(),
|
|
799
|
+
flowId: z.uuid(),
|
|
800
|
+
});
|
|
801
|
+
/**
|
|
802
|
+
* Successful response
|
|
803
|
+
*/
|
|
804
|
+
export const zGetFlowResponse = z.object({
|
|
805
|
+
data: z.object({
|
|
806
|
+
id: z.uuid(),
|
|
807
|
+
label: z.string(),
|
|
808
|
+
description: z.string().nullable(),
|
|
809
|
+
node_type: z.enum(["flow"]),
|
|
810
|
+
status: z.enum([
|
|
811
|
+
"BACKLOG",
|
|
812
|
+
"NOT_STARTED",
|
|
813
|
+
"IN_PLANNING",
|
|
814
|
+
"READY_FOR_DEVELOPMENT",
|
|
815
|
+
"IN_DEVELOPMENT",
|
|
816
|
+
"BLOCKED",
|
|
817
|
+
"REVIEW",
|
|
818
|
+
"COMPLETE",
|
|
819
|
+
]),
|
|
820
|
+
priority: z.string().nullable(),
|
|
821
|
+
roadmap_status: z
|
|
822
|
+
.enum(["now", "next", "later", "someday", "released"])
|
|
823
|
+
.optional(),
|
|
824
|
+
parent_id: z.uuid().nullable(),
|
|
825
|
+
parent_feature_id: z.uuid().nullish(),
|
|
826
|
+
project_id: z.uuid(),
|
|
827
|
+
workspace_id: z.uuid(),
|
|
828
|
+
position_x: z.number().optional(),
|
|
829
|
+
position_y: z.number().optional(),
|
|
830
|
+
step_count: z.int().gte(0),
|
|
831
|
+
}),
|
|
832
|
+
error: z.unknown(),
|
|
833
|
+
metadata: z.object({
|
|
834
|
+
timestamp: z.string(),
|
|
835
|
+
request_id: z.string(),
|
|
836
|
+
action_hints: z.array(z.unknown()).optional(),
|
|
837
|
+
}),
|
|
838
|
+
});
|
|
839
|
+
export const zUpdateFlowBody = z.object({
|
|
840
|
+
label: z.string().min(1).optional(),
|
|
841
|
+
description: z.string().optional(),
|
|
842
|
+
status: z
|
|
843
|
+
.enum([
|
|
844
|
+
"BACKLOG",
|
|
845
|
+
"NOT_STARTED",
|
|
846
|
+
"IN_PLANNING",
|
|
847
|
+
"READY_FOR_DEVELOPMENT",
|
|
848
|
+
"IN_DEVELOPMENT",
|
|
849
|
+
"BLOCKED",
|
|
850
|
+
"REVIEW",
|
|
851
|
+
"COMPLETE",
|
|
852
|
+
])
|
|
853
|
+
.optional(),
|
|
854
|
+
priority: z.enum(["MUST_HAVE", "SHOULD_HAVE", "BACKLOG"]).optional(),
|
|
855
|
+
roadmap_status: z
|
|
856
|
+
.enum(["now", "next", "later", "someday", "released"])
|
|
857
|
+
.optional(),
|
|
858
|
+
parent_id: z.uuid().optional(),
|
|
859
|
+
});
|
|
860
|
+
export const zUpdateFlowPath = z.object({
|
|
861
|
+
projectId: z.uuid(),
|
|
862
|
+
flowId: z.uuid(),
|
|
863
|
+
});
|
|
864
|
+
/**
|
|
865
|
+
* Successful response
|
|
866
|
+
*/
|
|
867
|
+
export const zUpdateFlowResponse = z.object({
|
|
868
|
+
data: z.object({
|
|
869
|
+
id: z.uuid(),
|
|
870
|
+
label: z.string(),
|
|
871
|
+
description: z.string().nullable(),
|
|
872
|
+
node_type: z.enum(["flow"]),
|
|
873
|
+
status: z.enum([
|
|
874
|
+
"BACKLOG",
|
|
875
|
+
"NOT_STARTED",
|
|
876
|
+
"IN_PLANNING",
|
|
877
|
+
"READY_FOR_DEVELOPMENT",
|
|
878
|
+
"IN_DEVELOPMENT",
|
|
879
|
+
"BLOCKED",
|
|
880
|
+
"REVIEW",
|
|
881
|
+
"COMPLETE",
|
|
882
|
+
]),
|
|
883
|
+
priority: z.string().nullable(),
|
|
884
|
+
roadmap_status: z
|
|
885
|
+
.enum(["now", "next", "later", "someday", "released"])
|
|
886
|
+
.optional(),
|
|
887
|
+
parent_id: z.uuid().nullable(),
|
|
888
|
+
parent_feature_id: z.uuid().nullish(),
|
|
889
|
+
project_id: z.uuid(),
|
|
890
|
+
workspace_id: z.uuid(),
|
|
891
|
+
position_x: z.number().optional(),
|
|
892
|
+
position_y: z.number().optional(),
|
|
893
|
+
step_count: z.int().gte(0),
|
|
894
|
+
}),
|
|
895
|
+
error: z.unknown(),
|
|
896
|
+
metadata: z.object({
|
|
897
|
+
timestamp: z.string(),
|
|
898
|
+
request_id: z.string(),
|
|
899
|
+
action_hints: z.array(z.unknown()).optional(),
|
|
900
|
+
}),
|
|
901
|
+
});
|
|
902
|
+
export const zGetFlowTreePath = z.object({
|
|
903
|
+
projectId: z.uuid(),
|
|
904
|
+
flowId: z.uuid(),
|
|
905
|
+
});
|
|
906
|
+
/**
|
|
907
|
+
* Successful response
|
|
908
|
+
*/
|
|
909
|
+
export const zGetFlowTreeResponse = z.object({
|
|
910
|
+
data: z.unknown().optional(),
|
|
911
|
+
error: z.unknown(),
|
|
912
|
+
metadata: z.object({
|
|
913
|
+
timestamp: z.string(),
|
|
914
|
+
request_id: z.string(),
|
|
915
|
+
action_hints: z.array(z.unknown()).optional(),
|
|
916
|
+
}),
|
|
917
|
+
});
|
|
751
918
|
export const zListStoriesPath = z.object({
|
|
752
919
|
projectId: z.uuid(),
|
|
753
920
|
});
|
|
@@ -23,8 +23,9 @@ const COMPACT_ART = [
|
|
|
23
23
|
];
|
|
24
24
|
const START_COLOR = { r: 0, g: 229, b: 255 }; // #00e5ff
|
|
25
25
|
const END_COLOR = { r: 224, g: 64, b: 251 }; // #e040fb
|
|
26
|
-
const lerp = (start, end, t) => Math.round(start + (end - start) * t);
|
|
27
|
-
|
|
26
|
+
export const lerp = (start, end, t) => Math.round(start + (end - start) * t);
|
|
27
|
+
/** Apply a linear RGB gradient over a single-line string. Pure. */
|
|
28
|
+
export const applyGradient = (text, width) => {
|
|
28
29
|
return text
|
|
29
30
|
.split('')
|
|
30
31
|
.map((char, i) => {
|
|
@@ -37,14 +38,16 @@ const applyGradient = (text, width) => {
|
|
|
37
38
|
.join('');
|
|
38
39
|
};
|
|
39
40
|
const SPINNER_FRAMES = ['β ', 'β ', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ', 'β '];
|
|
40
|
-
const STATUS_CONFIG = {
|
|
41
|
+
export const STATUS_CONFIG = {
|
|
41
42
|
'checking-connectivity': { label: 'Checking Connectivity', icon: SPINNER_FRAMES, color: 'yellow' },
|
|
42
43
|
'refreshing-token': { label: 'Refreshing Token', icon: SPINNER_FRAMES, color: 'yellow' },
|
|
43
44
|
ready: { label: 'Ready', icon: 'β', color: 'green' },
|
|
44
45
|
'no-connection': { label: 'No Connection', icon: 'β', color: 'red' },
|
|
45
46
|
'update-required': { label: 'Update Required', icon: 'β ', color: 'magenta' },
|
|
46
47
|
};
|
|
47
|
-
|
|
48
|
+
/** Resolve the status config (label/icon/color) for a CliStatus. Pure. */
|
|
49
|
+
export const getStatusConfig = (status) => STATUS_CONFIG[status];
|
|
50
|
+
export const getArt = (variant) => {
|
|
48
51
|
const art = variant === 'compact' ? MIDDY_ART : ART;
|
|
49
52
|
const width = Math.max(...art.map((line) => line.length));
|
|
50
53
|
return { art, width };
|
|
@@ -59,7 +62,7 @@ export function renderHeaderString(version, status = 'ready', headerVariant = 'f
|
|
|
59
62
|
return `${chalk.dim('β ')}${applyGradient(line + padding, width)}${chalk.dim(' β')}`;
|
|
60
63
|
})
|
|
61
64
|
.join('\n');
|
|
62
|
-
const currentStatus =
|
|
65
|
+
const currentStatus = getStatusConfig(status);
|
|
63
66
|
const icon = Array.isArray(currentStatus.icon) ? currentStatus.icon[0] : currentStatus.icon;
|
|
64
67
|
const statusLabel = chalk[currentStatus.color](`${currentStatus.label} ${icon}`);
|
|
65
68
|
const statusBar = chalk.dim(`[ 4ge CLI | v${version} | Status: ${statusLabel} ]`);
|
|
@@ -82,7 +85,7 @@ export default function CliHeader({ version, status = 'ready', headerVariant = '
|
|
|
82
85
|
const { art, width } = getArt(headerVariant);
|
|
83
86
|
const borderTop = `β${'β'.repeat(width + 2)}β`;
|
|
84
87
|
const borderBottom = `β${'β'.repeat(width + 2)}β`;
|
|
85
|
-
const currentStatus =
|
|
88
|
+
const currentStatus = getStatusConfig(status);
|
|
86
89
|
const icon = Array.isArray(currentStatus.icon) ? currentStatus.icon[frame] : currentStatus.icon;
|
|
87
90
|
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: borderTop }), art.map((line, i) => {
|
|
88
91
|
const padding = ' '.repeat(width - line.length);
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Map a status value to a chalk/ink color token.
|
|
5
|
+
* Pure: given a value, returns 'green' | 'yellow' | 'red' | undefined.
|
|
6
|
+
* Exported for unit testing β the mapping is logic, the JSX is wiring.
|
|
7
|
+
*/
|
|
8
|
+
export const getStatusColor = (value) => {
|
|
4
9
|
if (typeof value !== 'string')
|
|
5
10
|
return undefined;
|
|
6
11
|
const upper = value.toUpperCase();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4ge/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "4ge Service Architecture Bridge & CLI",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dev": "node --env-file=.env.local ./bin/dev.js",
|
|
39
39
|
"test": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs run",
|
|
40
40
|
"test:watch": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs",
|
|
41
|
+
"test:coverage": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs run --coverage",
|
|
41
42
|
"lint": "eslint src/ --ext .ts",
|
|
42
43
|
"format": "prettier --write \"src/**/*.ts\"",
|
|
43
44
|
"sync-api": "openapi-ts && prettier --write \"src/generated/api/**/*.ts\"",
|
package/skills/4ge-cli.md
CHANGED
|
@@ -80,6 +80,7 @@ Use it for network-flaky operations.
|
|
|
80
80
|
|-----------|----------|-----------|
|
|
81
81
|
| **project** | `create`, `info`, `list`, `pull`, `update` | `--workspace` (create), `--name`, `--description`, `--template`, `--force`, `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
|
|
82
82
|
| **epic** | `list`, `show`, `pull`, `update` | `--epic-id` / `--id` (pull), `--status`, `--title`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
|
|
83
|
+
| **flow** | `list`, `show`, `pull`, `update` | `--id`, `--feature-id` (list), `--recursive` (pull β full flow step tree), `--status`, `--title` (update), `--description` (update), `--priority` (update), `--roadmap-status` (update), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
|
|
83
84
|
| **story** | `list`, `show`, `pull`, `update` | `--story-id` / `--id` (pull), `--status`, `--title`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
|
|
84
85
|
| **feature** | `list`, `show`, `pull`, `update` | `--feature-id` / `--id` (pull), `--status`, `--roadmap`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
|
|
85
86
|
| **idea** | `create`, `graduate`, `list`, `show`, `update` | `<ID>` (positional for show), `--title`, `--workspace`, `--dry-run`, `--retry` |
|
|
@@ -102,6 +103,76 @@ No `--project-id` flag is needed for those namespaces.
|
|
|
102
103
|
|
|
103
104
|
Flags always override config; bare `4ge <ns>:pull --id <id>` honours the config defaults.
|
|
104
105
|
|
|
106
|
+
### Plan data model β parent-link semantics
|
|
107
|
+
|
|
108
|
+
**Read this before reconstructing a hierarchy from `list`/`pull --json` output.
|
|
109
|
+
A wrong field will falsely make everything look orphaned.**
|
|
110
|
+
|
|
111
|
+
Each project plan node (epic / story / feature / flow step) carries TWO
|
|
112
|
+
parent-link fields. They coexist by design β neither is legacy β and they
|
|
113
|
+
serve different relationships:
|
|
114
|
+
|
|
115
|
+
| Field | Meaning | Populated on |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `parent_id` | The **hierarchy tree** edge. epic β story β feature, plus step β step containment inside flows. | epics, stories, features, flow steps |
|
|
118
|
+
| `parent_feature_id` | A **flow-ownership** edge. Used ONLY by flow-step nodes (`node_type`: `useraction` / `systemresponse` / `conditional`) to attach to their owning feature. These steps deliberately have `parent_id` null (or pointing at another step). | flow steps only |
|
|
119
|
+
|
|
120
|
+
**Canonical hierarchy is `epic β story β feature`** (parent points UP
|
|
121
|
+
toward the epic), and the tree read bottom-up is:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
feature.parent_id β a story id β (story.parent_id) β an epic
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Not feature β epic directly β the story is the mandatory intermediate node.
|
|
128
|
+
|
|
129
|
+
#### Rules agents must follow
|
|
130
|
+
|
|
131
|
+
1. **For features and stories, `parent_feature_id` is null by design.**
|
|
132
|
+
Features and stories are never flow steps, so they use `parent_id`
|
|
133
|
+
exclusively. A null `parent_feature_id` on a feature/story is NOT a
|
|
134
|
+
broken link and NOT an orphan. Aggregating `parent_feature_id` to
|
|
135
|
+
reconstruct the tree will falsely report "orphans everywhere" β do
|
|
136
|
+
not do this.
|
|
137
|
+
|
|
138
|
+
2. **To reconstruct the hierarchy from list output, use `parent_id`,
|
|
139
|
+
never `parent_feature_id`:**
|
|
140
|
+
- `feature.parent_id` β a story id (the story that owns the feature).
|
|
141
|
+
- `story.parent_id` β an epic id.
|
|
142
|
+
- Group features by epic: resolve `feature.parent_id` β story, then
|
|
143
|
+
`story.parent_id` β epic.
|
|
144
|
+
|
|
145
|
+
3. **`children: []` under a filtered query on a flow-bearing feature is
|
|
146
|
+
a known, narrow bug β not proof of corruption.** Under an active
|
|
147
|
+
roadmap/status/priority filter, flow-step nodes linked ONLY via
|
|
148
|
+
`parent_feature_id` (no usable `parent_id`) can be pruned in the tree
|
|
149
|
+
builder and surface as `children: []` on their owning feature. This
|
|
150
|
+
affects **flow steps only** β never the `parent_id` epicβstoryβfeature
|
|
151
|
+
backbone. If you see empty `children` only under a filtered query on a
|
|
152
|
+
feature that bears flows, cite this caveat before claiming the
|
|
153
|
+
hierarchy is broken.
|
|
154
|
+
|
|
155
|
+
*Contract:* the CLI surfaces the server's tree as-is. There is no CLI
|
|
156
|
+
flag to work around the flow-step pruning case; if you hit it, query
|
|
157
|
+
without the filter, or pull the flow's own step tree instead:
|
|
158
|
+
`flow:list --feature-id <featureId> --json` to resolve the flow id, then
|
|
159
|
+
`flow:pull --id <flowId> --recursive --json` (the flow step tree is not
|
|
160
|
+
subject to the same pruning).
|
|
161
|
+
|
|
162
|
+
#### Quick check: is the hierarchy intact?
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# Every feature's parent_id should resolve to a story id in story:list.
|
|
166
|
+
4ge feature:list --json # collect feature[*].parent_id
|
|
167
|
+
4ge story:list --json # every parent_id from above must appear here
|
|
168
|
+
# Every story's parent_id should resolve to an epic id in epic:list.
|
|
169
|
+
4ge story:list --json # collect story[*].parent_id
|
|
170
|
+
4ge epic:list --json # every parent_id from above must appear here
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
If those joins are complete, the hierarchy is intact regardless of how
|
|
174
|
+
many `parent_feature_id` fields are null.
|
|
175
|
+
|
|
105
176
|
## π Common Workflow Patterns
|
|
106
177
|
|
|
107
178
|
1. **Explore projects**: `4ge project:list --json`
|