@forgehive/forge-cli 0.2.9 → 0.2.11

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
@@ -7,9 +7,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const minimist_1 = __importDefault(require("minimist"));
8
8
  const runner_1 = __importDefault(require("./runner"));
9
9
  const args = (0, minimist_1.default)(process.argv.slice(2));
10
- runner_1.default.handler(args).then(data => {
10
+ runner_1.default.handler(args).then((data) => {
11
+ const { silent, outcome, result } = data;
12
+ if (silent) {
13
+ return;
14
+ }
11
15
  console.log('===============================================');
12
- console.log('Outcome', data);
16
+ console.log(`Outcome: ${outcome}`);
17
+ console.log('===============================================');
18
+ console.log('Result', result);
13
19
  console.log('===============================================');
14
20
  }).catch((e) => {
15
21
  console.error(e);
package/dist/runner.js CHANGED
@@ -18,6 +18,8 @@ const remove_2 = require("./tasks/runner/remove");
18
18
  const bundle_1 = require("./tasks/runner/bundle");
19
19
  const publish_1 = require("./tasks/task/publish");
20
20
  const download_1 = require("./tasks/task/download");
21
+ const replay_1 = require("./tasks/task/replay");
22
+ const download_2 = require("./tasks/fixture/download");
21
23
  const add_1 = require("./tasks/auth/add");
22
24
  const switch_1 = require("./tasks/auth/switch");
23
25
  const list_1 = require("./tasks/auth/list");
@@ -39,10 +41,13 @@ runner.load('task:run', run_1.run);
39
41
  runner.load('task:remove', remove_1.remove);
40
42
  runner.load('task:publish', publish_1.publish);
41
43
  runner.load('task:download', download_1.download);
44
+ runner.load('task:replay', replay_1.replay);
42
45
  // Runner commands
43
46
  runner.load('runner:create', create_1.create);
44
47
  runner.load('runner:remove', remove_2.remove);
45
48
  runner.load('runner:bundle', bundle_1.bundle);
49
+ // Fixture commands
50
+ runner.load('fixture:download', download_2.download);
46
51
  // Auth commands
47
52
  runner.load('auth:add', add_1.add);
48
53
  runner.load('auth:switch', switch_1.switchProfile);
@@ -52,9 +57,10 @@ runner.load('auth:remove', remove_3.remove);
52
57
  runner.setHandler(async (data) => {
53
58
  const parsedArgs = runner.parseArguments(data);
54
59
  const { taskName, action, args } = parsedArgs;
55
- console.log('========================================');
60
+ console.log('===============================================');
56
61
  console.log(`Running: ${taskName} ${action ? action : ''} ${JSON.stringify(args)}`);
57
- console.log('========================================');
62
+ console.log('===============================================');
63
+ let silent = false;
58
64
  const task = runner.getTask(taskName);
59
65
  if (!task) {
60
66
  throw new Error(`Task "${taskName}" not found`);
@@ -86,12 +92,28 @@ runner.setHandler(async (data) => {
86
92
  uuid
87
93
  });
88
94
  }
95
+ else if (taskName === 'task:replay') {
96
+ const { path, cache } = args;
97
+ result = await task.run({
98
+ descriptorName: action,
99
+ path,
100
+ cache
101
+ });
102
+ }
89
103
  else if (taskName === 'task:run') {
90
104
  result = await task.run({
91
105
  descriptorName: action,
92
106
  args
93
107
  });
94
108
  }
109
+ else if (taskName === 'fixture:download') {
110
+ const { uuid } = args;
111
+ result = await task.run({
112
+ descriptorName: action,
113
+ uuid
114
+ });
115
+ silent = true;
116
+ }
95
117
  else if (taskName === 'auth:add') {
96
118
  const { apiKey, apiSecret, url } = args;
97
119
  result = await task.run({
@@ -110,6 +132,7 @@ runner.setHandler(async (data) => {
110
132
  result = await task.run(args);
111
133
  }
112
134
  return {
135
+ silent,
113
136
  outcome: 'Success',
114
137
  taskName,
115
138
  result
@@ -0,0 +1,34 @@
1
+ import { Profile } from '../types';
2
+ interface FixtureData {
3
+ name: string;
4
+ boundaries: Record<string, unknown>;
5
+ [key: string]: unknown;
6
+ }
7
+ interface FixtureResponse {
8
+ fixture: FixtureData;
9
+ [key: string]: unknown;
10
+ }
11
+ export declare const download: import("@forgehive/task").TaskInstanceType<(argv: {
12
+ uuid: string;
13
+ }, boundaries: import("@forgehive/task").WrappedBoundaries<{
14
+ loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
15
+ loadConf: (args: {}) => Promise<Promise<import("../types").ForgeConf>>;
16
+ downloadFixture: (uuid: string, profile: Profile) => Promise<FixtureResponse>;
17
+ getCwd: () => Promise<string>;
18
+ persistFixture: (filePath: string, data: FixtureData) => Promise<{
19
+ path: string;
20
+ }>;
21
+ }>) => Promise<{
22
+ status: string;
23
+ path: string;
24
+ shortPath: string;
25
+ }>, {
26
+ loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
27
+ loadConf: (args: {}) => Promise<Promise<import("../types").ForgeConf>>;
28
+ downloadFixture: (uuid: string, profile: Profile) => Promise<FixtureResponse>;
29
+ getCwd: () => Promise<string>;
30
+ persistFixture: (filePath: string, data: FixtureData) => Promise<{
31
+ path: string;
32
+ }>;
33
+ }>;
34
+ export {};
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ // TASK: download
3
+ // Run this task with:
4
+ // forge task:run fixture:download --uuid [fixture-uuid]
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.download = void 0;
10
+ const task_1 = require("@forgehive/task");
11
+ const schema_1 = require("@forgehive/schema");
12
+ const axios_1 = __importDefault(require("axios"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const promises_1 = __importDefault(require("fs/promises"));
15
+ const loadCurrent_1 = require("../auth/loadCurrent");
16
+ const load_1 = require("../conf/load");
17
+ const description = 'Download a fixture by UUID to a path based on task descriptor returned from API';
18
+ const schema = new schema_1.Schema({
19
+ uuid: schema_1.Schema.string()
20
+ });
21
+ const boundaries = {
22
+ loadCurrentProfile: loadCurrent_1.loadCurrent.asBoundary(),
23
+ loadConf: load_1.load.asBoundary(),
24
+ downloadFixture: async (uuid, profile) => {
25
+ const downloadUrl = `${profile.url}/api/fixture/${uuid}`;
26
+ console.log(`Downloading fixture from ${downloadUrl}...`);
27
+ const authToken = `${profile.apiKey}:${profile.apiSecret}`;
28
+ const response = await axios_1.default.get(downloadUrl, {
29
+ headers: {
30
+ Authorization: `Bearer ${authToken}`,
31
+ 'Content-Type': 'application/json'
32
+ }
33
+ });
34
+ return response.data;
35
+ },
36
+ getCwd: async () => {
37
+ return process.cwd();
38
+ },
39
+ persistFixture: async (filePath, data) => {
40
+ const dirPath = path_1.default.dirname(filePath);
41
+ await promises_1.default.mkdir(dirPath, { recursive: true });
42
+ await promises_1.default.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
43
+ return {
44
+ path: filePath
45
+ };
46
+ }
47
+ };
48
+ exports.download = (0, task_1.createTask)(schema, boundaries, async function ({ uuid }, { downloadFixture, getCwd, persistFixture, loadCurrentProfile, loadConf }) {
49
+ console.log('==================================================');
50
+ console.log(`Attempting to download fixture with uuid: ${uuid}`);
51
+ console.log('==================================================');
52
+ const profile = await loadCurrentProfile({});
53
+ const cwd = await getCwd();
54
+ const forge = await loadConf({});
55
+ // Download from hive api server first to get the task descriptor
56
+ let response;
57
+ try {
58
+ response = await downloadFixture(uuid, profile);
59
+ }
60
+ catch (e) {
61
+ const error = e;
62
+ console.error('Error downloading fixture:', error.message, error.status);
63
+ if (error.status === 404) {
64
+ throw new Error('Fixture not found');
65
+ }
66
+ throw new Error('Failed to download fixture');
67
+ }
68
+ const fixture = response.fixture;
69
+ const taskName = fixture.taskName;
70
+ // Determine the output path using forge fixtures path and task descriptor
71
+ const fixturesBasePath = forge.paths.fixtures || 'fixtures';
72
+ const fixtureDir = path_1.default.join(fixturesBasePath, taskName);
73
+ const fixturePath = path_1.default.join(fixtureDir, `${uuid}.json`);
74
+ const filePath = path_1.default.resolve(cwd, fixturePath);
75
+ // Persist fixture to file
76
+ await persistFixture(filePath, response.fixture);
77
+ // Get the relative path for display in the replay command
78
+ const shortPath = path_1.default.join(taskName, `${uuid}.json`);
79
+ console.log(`
80
+ ==================================================
81
+ Fixture download completed!
82
+ ==================================================
83
+ Fixture UUID: ${uuid}
84
+ Task Name: ${taskName}
85
+ Saved to: ${filePath}
86
+ ==================================================
87
+ Boundaries: ${Object.keys(fixture.boundaries).join(', ')}
88
+ ==================================================
89
+ Replay with:
90
+ forge task:replay ${taskName} --path ${shortPath}
91
+ ==================================================
92
+ `);
93
+ return {
94
+ status: 'Downloaded',
95
+ path: filePath,
96
+ shortPath: shortPath
97
+ };
98
+ });
99
+ exports.download.setDescription(description);
@@ -31,9 +31,9 @@ exports.init = (0, task_1.createTask)(schema, boundaries, async function (argv,
31
31
  },
32
32
  paths: {
33
33
  logs: 'logs/',
34
+ fixtures: 'fixtures/',
34
35
  tasks: 'src/tasks/',
35
36
  runners: 'src/runners/',
36
- fixtures: 'src/tests/fixtures',
37
37
  tests: 'src/tests/'
38
38
  },
39
39
  infra: {
@@ -0,0 +1,49 @@
1
+ import { type ForgeConf, type Profile } from '../types';
2
+ interface Fixture {
3
+ fixtureUUID: string;
4
+ taskName: string;
5
+ projectName: string;
6
+ type: 'success' | 'error';
7
+ input: Record<string, unknown>;
8
+ output: Record<string, unknown>;
9
+ boundaries: Record<string, unknown>;
10
+ context: Record<string, unknown>;
11
+ }
12
+ export declare const replay: import("@forgehive/task").TaskInstanceType<(argv: {
13
+ path: string;
14
+ descriptorName: string;
15
+ cache?: string | undefined;
16
+ }, boundaries: import("@forgehive/task").WrappedBoundaries<{
17
+ readFixture: (filePath: string) => Promise<Fixture>;
18
+ loadConf: (args: {}) => Promise<Promise<ForgeConf>>;
19
+ loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
20
+ bundleCreate: (args: {
21
+ entryPoint: string;
22
+ outputFile: string;
23
+ }) => Promise<Promise<{
24
+ outputFile: string;
25
+ }>>;
26
+ bundleLoad: (args: {
27
+ bundlePath: string;
28
+ }) => Promise<Promise<any>>;
29
+ ensureBuildsFolder: () => Promise<string>;
30
+ verifyLogFolder: (logsPath: string) => Promise<boolean>;
31
+ sendLogToAPI: (profile: Profile, projectName: string, taskName: string, logItem: unknown, fixtureUUID: string) => Promise<boolean>;
32
+ }>) => Promise<any>, {
33
+ readFixture: (filePath: string) => Promise<Fixture>;
34
+ loadConf: (args: {}) => Promise<Promise<ForgeConf>>;
35
+ loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
36
+ bundleCreate: (args: {
37
+ entryPoint: string;
38
+ outputFile: string;
39
+ }) => Promise<Promise<{
40
+ outputFile: string;
41
+ }>>;
42
+ bundleLoad: (args: {
43
+ bundlePath: string;
44
+ }) => Promise<Promise<any>>;
45
+ ensureBuildsFolder: () => Promise<string>;
46
+ verifyLogFolder: (logsPath: string) => Promise<boolean>;
47
+ sendLogToAPI: (profile: Profile, projectName: string, taskName: string, logItem: unknown, fixtureUUID: string) => Promise<boolean>;
48
+ }>;
49
+ export {};
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ // TASK: replay
3
+ // Run this task with:
4
+ // forge task:run task:replay
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.replay = void 0;
10
+ const task_1 = require("@forgehive/task");
11
+ const schema_1 = require("@forgehive/schema");
12
+ const promises_1 = __importDefault(require("fs/promises"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const axios_1 = __importDefault(require("axios"));
16
+ const create_1 = require("../bundle/create");
17
+ const load_1 = require("../bundle/load");
18
+ const load_2 = require("../conf/load");
19
+ const loadCurrent_1 = require("../auth/loadCurrent");
20
+ const description = 'Replay a task execution from a specified path';
21
+ const schema = new schema_1.Schema({
22
+ descriptorName: schema_1.Schema.string(),
23
+ path: schema_1.Schema.string(),
24
+ cache: schema_1.Schema.string().optional()
25
+ });
26
+ const boundaries = {
27
+ readFixture: async (filePath) => {
28
+ const fileContent = await promises_1.default.readFile(filePath, 'utf-8');
29
+ const parsedData = JSON.parse(fileContent);
30
+ return parsedData;
31
+ },
32
+ loadConf: load_2.load.asBoundary(),
33
+ loadCurrentProfile: loadCurrent_1.loadCurrent.asBoundary(),
34
+ bundleCreate: create_1.create.asBoundary(),
35
+ bundleLoad: load_1.load.asBoundary(),
36
+ ensureBuildsFolder: async () => {
37
+ const buildsPath = path_1.default.join(os_1.default.homedir(), '.forge', 'builds');
38
+ try {
39
+ await promises_1.default.access(buildsPath);
40
+ }
41
+ catch {
42
+ await promises_1.default.mkdir(buildsPath, { recursive: true });
43
+ }
44
+ return buildsPath;
45
+ },
46
+ verifyLogFolder: async (logsPath) => {
47
+ // return true if the folder exists
48
+ try {
49
+ await promises_1.default.access(logsPath);
50
+ }
51
+ catch (error) {
52
+ return false;
53
+ }
54
+ return true;
55
+ },
56
+ sendLogToAPI: async (profile, projectName, taskName, logItem, fixtureUUID) => {
57
+ try {
58
+ const logsUrl = `${profile.url}/api/tasks/log-ingest`;
59
+ const authToken = `${profile.apiKey}:${profile.apiSecret}`;
60
+ await axios_1.default.post(logsUrl, {
61
+ projectName,
62
+ taskName,
63
+ logItem: JSON.stringify(logItem),
64
+ replayFrom: fixtureUUID
65
+ }, {
66
+ headers: {
67
+ Authorization: `Bearer ${authToken}`,
68
+ 'Content-Type': 'application/json'
69
+ }
70
+ });
71
+ console.log('===============================================');
72
+ console.log('Log sent to API... ', profile.name, profile.url);
73
+ console.log('Replay from fixture UUID:', fixtureUUID);
74
+ return true;
75
+ }
76
+ catch (e) {
77
+ const error = e;
78
+ console.error('Failed to send log to API:', error.message);
79
+ return false;
80
+ }
81
+ }
82
+ };
83
+ exports.replay = (0, task_1.createTask)(schema, boundaries, async function ({ descriptorName, path: fixturePath, cache }, { readFixture, loadConf, loadCurrentProfile, bundleCreate, bundleLoad, ensureBuildsFolder, verifyLogFolder, sendLogToAPI }) {
84
+ console.log('Input descriptorName:', descriptorName);
85
+ console.log('Input path:', fixturePath);
86
+ console.log('Input cache:', cache);
87
+ // Load forge configuration
88
+ const forge = await loadConf({});
89
+ const taskDescriptor = forge.tasks[descriptorName];
90
+ const projectName = forge.project.name;
91
+ if (taskDescriptor === undefined) {
92
+ throw new Error(`Task ${descriptorName} is not defined in forge.json`);
93
+ }
94
+ // Resolve the fixture path (check if absolute, if not make it relative to logs folder)
95
+ const resolvedFixturePath = path_1.default.isAbsolute(fixturePath)
96
+ ? fixturePath
97
+ : path_1.default.join(process.cwd(), forge.paths.fixtures, fixturePath);
98
+ // Read the file from the provided path
99
+ const fixture = await readFixture(resolvedFixturePath);
100
+ // Try to load profile, but continue if not found
101
+ let profile = null;
102
+ try {
103
+ profile = await loadCurrentProfile({});
104
+ }
105
+ catch (error) {
106
+ // Profile not found or not configured, continue without it
107
+ console.log('No profile found, logs will not be sent to remote API');
108
+ }
109
+ // Verify if log folder exists
110
+ const logFolderPath = path_1.default.join(process.cwd(), forge.paths.logs);
111
+ const logFolderExists = await verifyLogFolder(logFolderPath);
112
+ if (!logFolderExists) {
113
+ throw new Error(`Log folder "${logFolderPath}" does not exist`);
114
+ }
115
+ // Prepare paths
116
+ const entryPoint = path_1.default.join(process.cwd(), taskDescriptor.path);
117
+ const buildsPath = await ensureBuildsFolder();
118
+ const outputFile = path_1.default.join(buildsPath, `${descriptorName}.js`);
119
+ // Bundle the task
120
+ await bundleCreate({
121
+ entryPoint,
122
+ outputFile
123
+ });
124
+ // Load the bundled task
125
+ const bundle = await bundleLoad({
126
+ bundlePath: outputFile
127
+ });
128
+ // Get the task handler
129
+ const task = bundle[taskDescriptor.handler];
130
+ if (!task) {
131
+ throw new Error(`Handler "${taskDescriptor.handler}" not found in bundle`);
132
+ }
133
+ // Configure boundaries based on cache parameter if provided
134
+ const boundaryConfig = {};
135
+ if (cache) {
136
+ // Parse the comma-separated list and trim each item
137
+ const cacheBoundaries = cache.split(',').map((b) => b.trim());
138
+ // Log which boundaries will use cache mode
139
+ if (cacheBoundaries.length > 0) {
140
+ // Set each specified boundary to 'replay' mode
141
+ cacheBoundaries.forEach((boundary) => {
142
+ boundaryConfig[boundary] = 'replay';
143
+ });
144
+ }
145
+ }
146
+ console.log('==================================================');
147
+ console.log('UUID:', fixture.fixtureUUID);
148
+ console.log('Task name:', fixture.taskName);
149
+ console.log('Project name:', fixture.projectName);
150
+ console.log('Context:', fixture.context);
151
+ console.log('==================================================');
152
+ console.log('Replay:', fixture.input);
153
+ console.log('Boundaries:', JSON.stringify(fixture.boundaries, null, 2));
154
+ console.log('==================================================');
155
+ console.log('Boundary config:', boundaryConfig);
156
+ console.log('==================================================');
157
+ // Perform the replay
158
+ const [result, error, record] = await task.safeReplay({
159
+ input: fixture.input,
160
+ output: fixture.output,
161
+ boundaries: fixture.boundaries,
162
+ }, {
163
+ boundaries: boundaryConfig // Use configured boundary modes
164
+ });
165
+ // Send the log to API if profile is available
166
+ if (profile) {
167
+ try {
168
+ await sendLogToAPI(profile, projectName, descriptorName, record, fixture.fixtureUUID);
169
+ }
170
+ catch (e) {
171
+ console.error('Failed to send log to API:', e);
172
+ }
173
+ }
174
+ if (error) {
175
+ throw new Error(error.message);
176
+ }
177
+ return result;
178
+ });
179
+ exports.replay.setDescription(description);
package/forge.json CHANGED
@@ -4,9 +4,9 @@
4
4
  },
5
5
  "paths": {
6
6
  "logs": "logs/",
7
+ "fixtures": "fixtures",
7
8
  "tasks": "src/tasks/",
8
9
  "runners": "src/runners/",
9
- "fixtures": "src/tests/fixtures",
10
10
  "tests": "src/tests/"
11
11
  },
12
12
  "infra": {
@@ -77,6 +77,14 @@
77
77
  "auth:remove": {
78
78
  "path": "src/tasks/auth/remove.ts",
79
79
  "handler": "remove"
80
+ },
81
+ "task:replay": {
82
+ "path": "src/tasks/task/replay.ts",
83
+ "handler": "replay"
84
+ },
85
+ "fixture:download": {
86
+ "path": "src/tasks/fixture/download.ts",
87
+ "handler": "download"
80
88
  }
81
89
  },
82
90
  "runners": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgehive/forge-cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "TypeScript CLI application",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -10,10 +10,10 @@
10
10
  "publishConfig": {
11
11
  "access": "public",
12
12
  "dependencies": {
13
- "@forgehive/record-tape": "^0.1.1",
14
- "@forgehive/runner": "^0.1.7",
13
+ "@forgehive/record-tape": "^0.1.4",
14
+ "@forgehive/runner": "^0.1.10",
15
15
  "@forgehive/schema": "^0.1.4",
16
- "@forgehive/task": "^0.1.7",
16
+ "@forgehive/task": "^0.1.11",
17
17
  "esbuild": "^0.25.0",
18
18
  "handlebars": "^4.7.8",
19
19
  "minimist": "^1.2.8"
@@ -25,10 +25,10 @@
25
25
  "esbuild": "^0.25.0",
26
26
  "handlebars": "^4.7.8",
27
27
  "minimist": "^1.2.8",
28
- "@forgehive/runner": "0.1.8",
29
- "@forgehive/record-tape": "0.1.2",
28
+ "@forgehive/runner": "0.1.10",
29
+ "@forgehive/record-tape": "0.1.4",
30
30
  "@forgehive/schema": "0.1.4",
31
- "@forgehive/task": "0.1.8"
31
+ "@forgehive/task": "0.1.11"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/jest": "^29.5.3",
package/src/index.ts CHANGED
@@ -5,9 +5,23 @@ import runner from './runner'
5
5
 
6
6
  const args = minimist(process.argv.slice(2))
7
7
 
8
- runner.handler(args).then(data => {
8
+ type RunnerResult = {
9
+ silent: boolean
10
+ outcome: 'Success' | 'Failure'
11
+ taskName: string
12
+ result: unknown
13
+ }
14
+
15
+ runner.handler(args).then((data) => {
16
+ const { silent, outcome, result } = data as RunnerResult
17
+ if (silent) {
18
+ return
19
+ }
20
+
21
+ console.log('===============================================')
22
+ console.log(`Outcome: ${outcome}`)
9
23
  console.log('===============================================')
10
- console.log('Outcome', data)
24
+ console.log('Result', result)
11
25
  console.log('===============================================')
12
26
  }).catch((e) => {
13
27
  console.error(e)
package/src/runner.ts CHANGED
@@ -18,6 +18,9 @@ import { remove as removeRunner } from './tasks/runner/remove'
18
18
  import { bundle as bundleRunner } from './tasks/runner/bundle'
19
19
  import { publish as publishTask } from './tasks/task/publish'
20
20
  import { download as downloadTask } from './tasks/task/download'
21
+ import { replay as replayTask } from './tasks/task/replay'
22
+
23
+ import { download as downloadFixture } from './tasks/fixture/download'
21
24
 
22
25
  import { add as addProfile } from './tasks/auth/add'
23
26
  import { switchProfile } from './tasks/auth/switch'
@@ -48,12 +51,16 @@ runner.load('task:run', taskRunCommand)
48
51
  runner.load('task:remove', taskRemoveCommand)
49
52
  runner.load('task:publish', publishTask)
50
53
  runner.load('task:download', downloadTask)
54
+ runner.load('task:replay', replayTask)
51
55
 
52
56
  // Runner commands
53
57
  runner.load('runner:create', createRunner)
54
58
  runner.load('runner:remove', removeRunner)
55
59
  runner.load('runner:bundle', bundleRunner)
56
60
 
61
+ // Fixture commands
62
+ runner.load('fixture:download', downloadFixture)
63
+
57
64
  // Auth commands
58
65
  runner.load('auth:add', addProfile)
59
66
  runner.load('auth:switch', switchProfile)
@@ -65,10 +72,11 @@ runner.setHandler(async (data: ParsedArgs): Promise<unknown> => {
65
72
  const parsedArgs = runner.parseArguments(data)
66
73
  const { taskName, action, args } = parsedArgs
67
74
 
68
- console.log('========================================')
75
+ console.log('===============================================')
69
76
  console.log(`Running: ${taskName} ${action ? action : ''} ${JSON.stringify(args)}`)
70
- console.log('========================================')
77
+ console.log('===============================================')
71
78
 
79
+ let silent = false
72
80
  const task = runner.getTask(taskName)
73
81
  if (!task) {
74
82
  throw new Error(`Task "${taskName}" not found`)
@@ -101,11 +109,27 @@ runner.setHandler(async (data: ParsedArgs): Promise<unknown> => {
101
109
  descriptorName: action,
102
110
  uuid
103
111
  })
112
+ } else if (taskName === 'task:replay') {
113
+ const { path, cache } = args as { path: string, cache: string }
114
+
115
+ result = await task.run({
116
+ descriptorName: action,
117
+ path,
118
+ cache
119
+ })
104
120
  } else if (taskName === 'task:run') {
105
121
  result = await task.run({
106
122
  descriptorName: action,
107
123
  args
108
124
  })
125
+ } else if (taskName === 'fixture:download') {
126
+ const { uuid } = args as { uuid: string }
127
+
128
+ result = await task.run({
129
+ descriptorName: action,
130
+ uuid
131
+ })
132
+ silent = true
109
133
  } else if (taskName === 'auth:add') {
110
134
  const { apiKey, apiSecret, url } = args as { name: string, apiKey: string, apiSecret: string, url: string }
111
135
 
@@ -124,6 +148,7 @@ runner.setHandler(async (data: ParsedArgs): Promise<unknown> => {
124
148
  }
125
149
 
126
150
  return {
151
+ silent,
127
152
  outcome: 'Success',
128
153
  taskName,
129
154
  result
@@ -0,0 +1,136 @@
1
+ // TASK: download
2
+ // Run this task with:
3
+ // forge task:run fixture:download --uuid [fixture-uuid]
4
+
5
+ import { createTask } from '@forgehive/task'
6
+ import { Schema } from '@forgehive/schema'
7
+ import axios from 'axios'
8
+ import path from 'path'
9
+ import fs from 'fs/promises'
10
+ import { loadCurrent as loadCurrentProfile } from '../auth/loadCurrent'
11
+ import { load as loadConf } from '../conf/load'
12
+ import { Profile } from '../types'
13
+
14
+ // Define the Fixture data structure
15
+ interface FixtureData {
16
+ name: string;
17
+ boundaries: Record<string, unknown>;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ interface FixtureResponse {
22
+ fixture: FixtureData;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ const description = 'Download a fixture by UUID to a path based on task descriptor returned from API'
27
+
28
+ const schema = new Schema({
29
+ uuid: Schema.string()
30
+ })
31
+
32
+ const boundaries = {
33
+ loadCurrentProfile: loadCurrentProfile.asBoundary(),
34
+ loadConf: loadConf.asBoundary(),
35
+ downloadFixture: async (uuid: string, profile: Profile): Promise<FixtureResponse> => {
36
+ const downloadUrl = `${profile.url}/api/fixture/${uuid}`
37
+
38
+ console.log(`Downloading fixture from ${downloadUrl}...`)
39
+
40
+ const authToken = `${profile.apiKey}:${profile.apiSecret}`
41
+ const response = await axios.get(downloadUrl, {
42
+ headers: {
43
+ Authorization: `Bearer ${authToken}`,
44
+ 'Content-Type': 'application/json'
45
+ }
46
+ })
47
+
48
+ return response.data
49
+ },
50
+ getCwd: async (): Promise<string> => {
51
+ return process.cwd()
52
+ },
53
+ persistFixture: async (filePath: string, data: FixtureData): Promise<{ path: string }> => {
54
+ const dirPath = path.dirname(filePath)
55
+
56
+ await fs.mkdir(dirPath, { recursive: true })
57
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
58
+
59
+ return {
60
+ path: filePath
61
+ }
62
+ }
63
+ }
64
+
65
+ export const download = createTask(
66
+ schema,
67
+ boundaries,
68
+ async function ({ uuid }, {
69
+ downloadFixture,
70
+ getCwd,
71
+ persistFixture,
72
+ loadCurrentProfile,
73
+ loadConf
74
+ }) {
75
+ console.log('==================================================')
76
+ console.log(`Attempting to download fixture with uuid: ${uuid}`)
77
+ console.log('==================================================')
78
+
79
+ const profile = await loadCurrentProfile({})
80
+ const cwd = await getCwd()
81
+ const forge = await loadConf({})
82
+
83
+ // Download from hive api server first to get the task descriptor
84
+ let response
85
+ try {
86
+ response = await downloadFixture(uuid, profile)
87
+ } catch (e: unknown) {
88
+ const error = e as { status?: number, message: string }
89
+ console.error('Error downloading fixture:', error.message, error.status)
90
+
91
+ if (error.status === 404) {
92
+ throw new Error('Fixture not found')
93
+ }
94
+
95
+ throw new Error('Failed to download fixture')
96
+ }
97
+
98
+ const fixture = response.fixture as FixtureData
99
+ const taskName = fixture.taskName as string
100
+
101
+ // Determine the output path using forge fixtures path and task descriptor
102
+ const fixturesBasePath = forge.paths.fixtures || 'fixtures'
103
+ const fixtureDir = path.join(fixturesBasePath, taskName)
104
+ const fixturePath = path.join(fixtureDir, `${uuid}.json`)
105
+ const filePath = path.resolve(cwd, fixturePath)
106
+
107
+ // Persist fixture to file
108
+ await persistFixture(filePath, response.fixture)
109
+
110
+ // Get the relative path for display in the replay command
111
+ const shortPath = path.join(taskName, `${uuid}.json`)
112
+
113
+ console.log(`
114
+ ==================================================
115
+ Fixture download completed!
116
+ ==================================================
117
+ Fixture UUID: ${uuid}
118
+ Task Name: ${taskName}
119
+ Saved to: ${filePath}
120
+ ==================================================
121
+ Boundaries: ${Object.keys(fixture.boundaries).join(', ')}
122
+ ==================================================
123
+ Replay with:
124
+ forge task:replay ${taskName} --path ${shortPath}
125
+ ==================================================
126
+ `)
127
+
128
+ return {
129
+ status: 'Downloaded',
130
+ path: filePath,
131
+ shortPath: shortPath
132
+ }
133
+ }
134
+ )
135
+
136
+ download.setDescription(description)
package/src/tasks/init.ts CHANGED
@@ -36,9 +36,9 @@ export const init = createTask(
36
36
  },
37
37
  paths: {
38
38
  logs: 'logs/',
39
+ fixtures: 'fixtures/',
39
40
  tasks: 'src/tasks/',
40
41
  runners: 'src/runners/',
41
- fixtures: 'src/tests/fixtures',
42
42
  tests: 'src/tests/'
43
43
  },
44
44
  infra: {
@@ -0,0 +1,220 @@
1
+ // TASK: replay
2
+ // Run this task with:
3
+ // forge task:run task:replay
4
+
5
+ import { createTask } from '@forgehive/task'
6
+ import { Schema } from '@forgehive/schema'
7
+ import fs from 'fs/promises'
8
+ import path from 'path'
9
+ import os from 'os'
10
+ import axios from 'axios'
11
+
12
+ import { create as bundleCreate } from '../bundle/create'
13
+ import { load as bundleLoad } from '../bundle/load'
14
+ import { load as loadConf } from '../conf/load'
15
+ import { loadCurrent as loadCurrentProfile } from '../auth/loadCurrent'
16
+ import { type ForgeConf, type Profile } from '../types'
17
+
18
+ // Define the fixture structure type
19
+ interface Fixture {
20
+ fixtureUUID: string;
21
+ taskName: string;
22
+ projectName: string;
23
+ type: 'success' | 'error';
24
+ input: Record<string, unknown>;
25
+ output: Record<string, unknown>;
26
+ boundaries: Record<string, unknown>;
27
+ context: Record<string, unknown>;
28
+ }
29
+
30
+ const description = 'Replay a task execution from a specified path'
31
+
32
+ const schema = new Schema({
33
+ descriptorName: Schema.string(),
34
+ path: Schema.string(),
35
+ cache: Schema.string().optional()
36
+ })
37
+
38
+ const boundaries = {
39
+ readFixture: async (filePath: string): Promise<Fixture> => {
40
+ const fileContent = await fs.readFile(filePath, 'utf-8')
41
+ const parsedData = JSON.parse(fileContent) as Fixture
42
+
43
+ return parsedData
44
+ },
45
+ loadConf: loadConf.asBoundary(),
46
+ loadCurrentProfile: loadCurrentProfile.asBoundary(),
47
+ bundleCreate: bundleCreate.asBoundary(),
48
+ bundleLoad: bundleLoad.asBoundary(),
49
+ ensureBuildsFolder: async (): Promise<string> => {
50
+ const buildsPath = path.join(os.homedir(), '.forge', 'builds')
51
+ try {
52
+ await fs.access(buildsPath)
53
+ } catch {
54
+ await fs.mkdir(buildsPath, { recursive: true })
55
+ }
56
+
57
+ return buildsPath
58
+ },
59
+ verifyLogFolder: async (logsPath: string): Promise<boolean> => {
60
+ // return true if the folder exists
61
+ try {
62
+ await fs.access(logsPath)
63
+ } catch (error) {
64
+ return false
65
+ }
66
+
67
+ return true
68
+ },
69
+ sendLogToAPI: async (profile: Profile, projectName: string, taskName: string, logItem: unknown, fixtureUUID: string): Promise<boolean> => {
70
+ try {
71
+ const logsUrl = `${profile.url}/api/tasks/log-ingest`
72
+ const authToken = `${profile.apiKey}:${profile.apiSecret}`
73
+
74
+ await axios.post(logsUrl, {
75
+ projectName,
76
+ taskName,
77
+ logItem: JSON.stringify(logItem),
78
+ replayFrom: fixtureUUID
79
+ }, {
80
+ headers: {
81
+ Authorization: `Bearer ${authToken}`,
82
+ 'Content-Type': 'application/json'
83
+ }
84
+ })
85
+
86
+ console.log('===============================================')
87
+ console.log('Log sent to API... ', profile.name, profile.url)
88
+ console.log('Replay from fixture UUID:', fixtureUUID)
89
+
90
+ return true
91
+ } catch (e) {
92
+ const error = e as Error
93
+ console.error('Failed to send log to API:', error.message)
94
+ return false
95
+ }
96
+ }
97
+ }
98
+
99
+ export const replay = createTask(
100
+ schema,
101
+ boundaries,
102
+ async function ({ descriptorName, path: fixturePath, cache }, { readFixture, loadConf, loadCurrentProfile, bundleCreate, bundleLoad, ensureBuildsFolder, verifyLogFolder, sendLogToAPI }) {
103
+ console.log('Input descriptorName:', descriptorName)
104
+ console.log('Input path:', fixturePath)
105
+ console.log('Input cache:', cache)
106
+
107
+ // Load forge configuration
108
+ const forge: ForgeConf = await loadConf({})
109
+ const taskDescriptor = forge.tasks[descriptorName as keyof typeof forge.tasks]
110
+ const projectName = forge.project.name
111
+
112
+ if (taskDescriptor === undefined) {
113
+ throw new Error(`Task ${descriptorName} is not defined in forge.json`)
114
+ }
115
+
116
+ // Resolve the fixture path (check if absolute, if not make it relative to logs folder)
117
+ const resolvedFixturePath = path.isAbsolute(fixturePath)
118
+ ? fixturePath
119
+ : path.join(process.cwd(), forge.paths.fixtures, fixturePath)
120
+
121
+ // Read the file from the provided path
122
+ const fixture = await readFixture(resolvedFixturePath)
123
+
124
+ // Try to load profile, but continue if not found
125
+ let profile = null
126
+ try {
127
+ profile = await loadCurrentProfile({})
128
+ } catch (error) {
129
+ // Profile not found or not configured, continue without it
130
+ console.log('No profile found, logs will not be sent to remote API')
131
+ }
132
+
133
+ // Verify if log folder exists
134
+ const logFolderPath = path.join(process.cwd(), forge.paths.logs)
135
+ const logFolderExists = await verifyLogFolder(logFolderPath)
136
+ if (!logFolderExists) {
137
+ throw new Error(`Log folder "${logFolderPath}" does not exist`)
138
+ }
139
+
140
+ // Prepare paths
141
+ const entryPoint = path.join(process.cwd(), taskDescriptor.path)
142
+ const buildsPath = await ensureBuildsFolder()
143
+ const outputFile = path.join(buildsPath, `${descriptorName}.js`)
144
+
145
+ // Bundle the task
146
+ await bundleCreate({
147
+ entryPoint,
148
+ outputFile
149
+ })
150
+
151
+ // Load the bundled task
152
+ const bundle = await bundleLoad({
153
+ bundlePath: outputFile
154
+ })
155
+
156
+ // Get the task handler
157
+ const task = bundle[taskDescriptor.handler]
158
+
159
+ if (!task) {
160
+ throw new Error(`Handler "${taskDescriptor.handler}" not found in bundle`)
161
+ }
162
+
163
+ // Configure boundaries based on cache parameter if provided
164
+ const boundaryConfig: Record<string, string> = {}
165
+
166
+ if (cache) {
167
+ // Parse the comma-separated list and trim each item
168
+ const cacheBoundaries = cache.split(',').map((b: string) => b.trim())
169
+
170
+ // Log which boundaries will use cache mode
171
+ if (cacheBoundaries.length > 0) {
172
+ // Set each specified boundary to 'replay' mode
173
+ cacheBoundaries.forEach((boundary: string) => {
174
+ boundaryConfig[boundary] = 'replay'
175
+ })
176
+ }
177
+ }
178
+
179
+ console.log('==================================================')
180
+ console.log('UUID:', fixture.fixtureUUID)
181
+ console.log('Task name:', fixture.taskName)
182
+ console.log('Project name:', fixture.projectName)
183
+ console.log('Context:', fixture.context)
184
+ console.log('==================================================')
185
+ console.log('Replay:', fixture.input)
186
+ console.log('Boundaries:', JSON.stringify(fixture.boundaries, null, 2))
187
+ console.log('==================================================')
188
+ console.log('Boundary config:', boundaryConfig)
189
+ console.log('==================================================')
190
+
191
+ // Perform the replay
192
+ const [result, error, record] = await task.safeReplay(
193
+ {
194
+ input: fixture.input,
195
+ output: fixture.output,
196
+ boundaries: fixture.boundaries,
197
+ },
198
+ {
199
+ boundaries: boundaryConfig // Use configured boundary modes
200
+ }
201
+ )
202
+
203
+ // Send the log to API if profile is available
204
+ if (profile) {
205
+ try {
206
+ await sendLogToAPI(profile, projectName, descriptorName, record, fixture.fixtureUUID)
207
+ } catch (e) {
208
+ console.error('Failed to send log to API:', e)
209
+ }
210
+ }
211
+
212
+ if (error) {
213
+ throw new Error(error.message)
214
+ }
215
+
216
+ return result
217
+ }
218
+ )
219
+
220
+ replay.setDescription(description)