@forgehive/forge-cli 0.2.9 → 0.2.10

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