@skyramp/skyramp 1.3.7 → 1.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/skyramp",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "description": "module for leveraging skyramp cli functionality",
5
5
  "scripts": {
6
6
  "lint": "eslint 'src/**/*.js' 'src/**/*.ts' --fix",
@@ -24,7 +24,8 @@
24
24
  "@aws-sdk/client-s3": "^3.812.0",
25
25
  "fs": "^0.0.1-security",
26
26
  "js-yaml": "^4.1.0",
27
- "koffi": "2.5.12"
27
+ "koffi": "2.5.12",
28
+ "zod": "^3.25.3"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@typescript-eslint/eslint-plugin": "^6.14.0",
@@ -15,11 +15,6 @@ const testerInfoType = koffi.struct({
15
15
  error: 'char*',
16
16
  });
17
17
 
18
- const testerGenerateType = koffi.struct({
19
- generated_files: 'char*',
20
- error: 'char*',
21
- });
22
-
23
18
  const contractResponseType = koffi.struct({
24
19
  response: 'char*',
25
20
  error: 'char*',
@@ -55,7 +50,6 @@ const applyMockObjectWrapper = lib.func('applyMockObjectWrapper', 'string', ['st
55
50
  const initTargetWrapper = lib.func('initTargetWrapper', 'string', ['string']);
56
51
  const deployTargetWrapper = lib.func('deployTargetWrapper', 'string', ['string', 'string', 'string', 'string', 'string', 'string', 'bool']);
57
52
  const deleteTargetWrapper = lib.func('deleteTargetWrapper', 'string', ['string', 'string', 'string', 'string', 'string']);
58
- const runTesterGenerateRestWrapper = lib.func('runTesterGenerateRestWrapper', testerGenerateType, ['string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'int', 'bool', 'bool', 'bool']);
59
53
 
60
54
  const generateRestTestWrapper = lib.func('generateRestTestWrapper', 'string', ['string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'bool', 'bool', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'bool', 'string', 'bool', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'bool', 'string', 'string']);
61
55
  const generateRestMockWrapper = lib.func('generateRestMockWrapper', 'string', ['string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'bool', 'bool', 'string', 'string', 'string', 'string', 'string', 'string']);
@@ -666,41 +660,6 @@ class SkyrampClient {
666
660
  return mockDescription;
667
661
  }
668
662
 
669
- /**
670
- * Generates test scenarios based on the provided parameters.
671
- *
672
- * @param {Protocol} protocol - The protocol to be used.
673
- * @param {string} apiSchemaPath - The path to the API schema.
674
- * @param {string} alias - The alias for the generated tests.
675
- * @param {string} endpointPath - Endpoint REST path to filter for.
676
- * @param {Language} language - The programming language for the generated tests.
677
- * @param {string} tag - Tag to filter OpenAPI endpoint paths for.
678
- * @param {string} sampleRequestPath - The path to the sample request.
679
- * @param {int} port - The port number.
680
- * @param {boolean} generateRobot - Whether to generate robot tests.
681
- * @param {boolean} functionalScenario - Whether to generate functional scenarios.
682
- * @param {boolean} negativeScenario - Whether to generate negative scenarios.
683
- * @returns {Promise<string[]>} A promise that resolves with a list of generated test file paths.
684
- */
685
- async testerGenerate(protocol, apiSchemaPath, alias, endpointPath, language, tag, sampleRequestPath, port, generateRobot, functionalScenario, negativeScenario) {
686
- return new Promise((resolve, reject) => {
687
- runTesterGenerateRestWrapper.async(protocol, apiSchemaPath, alias, endpointPath, language, tag, sampleRequestPath, this.projectPath, port, generateRobot, functionalScenario, negativeScenario, (err, res) => {
688
- if (err) {
689
- console.error(`Error generating tests: ${err.name} - ${err.message}`);
690
- reject(err);
691
- } else if (res.error) {
692
- console.error(`Error generating tests: ${res.error}`);
693
- reject(new Error(res.error));
694
- } else {
695
- console.log(`Test generation completed successfully. Generated files: ${res.generated_files}`);
696
-
697
- const generatedTestNames = res.generated_files.split(',');
698
- resolve(generatedTestNames);
699
- }
700
- });
701
- });
702
- }
703
-
704
663
  /**
705
664
  * Sends a request to a Skyramp worker using the V2 API
706
665
  * @param {Object} options - The options for sending the request
package/src/index.d.ts CHANGED
@@ -19,3 +19,4 @@ export * from "./classes/AsyncTestStatus";
19
19
  export * from "./utils";
20
20
  export * from "./function";
21
21
  export * from "./classes/SmartPlaywright";
22
+ export * from "./workspace";
package/src/index.js CHANGED
@@ -20,6 +20,15 @@ const MockV2 = require('./classes/MockV2');
20
20
  const { getValue, getResponseValue, checkSchema, iterate, pushToolEvent } = require('./utils');
21
21
  const { checkStatusCode } = require('./function');
22
22
  const { newSkyrampPlaywrightPage, expect } = require('./classes/SmartPlaywright');
23
+ const {
24
+ workspaceConfigSchema,
25
+ serviceSchema,
26
+ WorkspaceConfigManager,
27
+ validateWorkspaceConfig,
28
+ createDefaultConfig,
29
+ WORKSPACE_DIR,
30
+ WORKSPACE_FILENAME,
31
+ } = require('./workspace');
23
32
 
24
33
  module.exports = {
25
34
  SkyrampClient,
@@ -50,4 +59,11 @@ module.exports = {
50
59
  pushToolEvent,
51
60
  newSkyrampPlaywrightPage,
52
61
  expect,
62
+ workspaceConfigSchema,
63
+ serviceSchema,
64
+ WorkspaceConfigManager,
65
+ validateWorkspaceConfig,
66
+ createDefaultConfig,
67
+ WORKSPACE_FILENAME,
68
+ WORKSPACE_DIR,
53
69
  }
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types (defined to match Zod schemas)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ // Note: These types match the runtime Zod schemas in workspace.js
8
+ // Changes to schemas should be reflected here
9
+
10
+ export interface ServiceApi {
11
+ schemaPath?: string;
12
+ authType?: "bearer" | "basic" | "oauth" | "apiKey" | "none";
13
+ authHeader?: string;
14
+ baseUrl?: string;
15
+ }
16
+
17
+ export interface ServiceRuntimeDetails {
18
+ serverStartCommand: string;
19
+ runtime: "local" | "docker" | "k8s";
20
+ dockerNetwork?: string;
21
+ k8sNamespace?: string;
22
+ k8sContext?: string;
23
+ }
24
+
25
+ export interface Service {
26
+ serviceName: string;
27
+ language?: "python" | "typescript" | "javascript" | "java";
28
+ framework?: "playwright" | "pytest" | "robot" | "junit";
29
+ outputDir: string;
30
+ api?: ServiceApi;
31
+ runtimeDetails?: ServiceRuntimeDetails;
32
+ }
33
+
34
+ export interface WorkspaceSection {
35
+ repoName?: string;
36
+ repoUrl?: string;
37
+ }
38
+
39
+ export interface MetadataSection {
40
+ schemaVersion: string;
41
+ mcpVersion: string;
42
+ executorVersion: string;
43
+ createdAt: string;
44
+ updatedAt: string;
45
+ }
46
+
47
+ export interface WorkspaceConfig {
48
+ workspace?: WorkspaceSection;
49
+ metadata?: MetadataSection;
50
+ services?: Service[];
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Zod schemas
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export const serviceSchema: z.ZodType<Service>;
58
+ export const workspaceConfigSchema: z.ZodType<WorkspaceConfig>;
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Validation
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export function validateWorkspaceConfig(
65
+ config: unknown,
66
+ ): z.SafeParseReturnType<WorkspaceConfig, WorkspaceConfig>;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export function createDefaultConfig(): WorkspaceConfig;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // WorkspaceConfigManager
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export class WorkspaceConfigManager {
79
+ constructor(workspacePath: string);
80
+
81
+ /** Check if workspace config file exists */
82
+ exists(): Promise<boolean>;
83
+
84
+ /** Get the absolute path to the config file */
85
+ getConfigPath(): string;
86
+
87
+ /** Get the workspace root path */
88
+ getWorkspacePath(): string;
89
+
90
+ /** Read and parse workspace config */
91
+ read(): Promise<WorkspaceConfig>;
92
+
93
+ /**
94
+ * Initialize workspace — creates .skyramp/workspace.yml with repo info.
95
+ * Auto-detects repoName and repoUrl from git when not provided.
96
+ */
97
+ initialize(workspaceInfo?: WorkspaceSection): Promise<WorkspaceConfig>;
98
+
99
+ /**
100
+ * Update the metadata section of an existing workspace config.
101
+ * Only the provided fields are merged; metadata.updatedAt is refreshed
102
+ * automatically.
103
+ */
104
+ updateMetadata(metadata: Partial<MetadataSection>): Promise<WorkspaceConfig>;
105
+
106
+ /**
107
+ * Add a single service entry to the services array.
108
+ * If a service with the same serviceName already exists it is replaced
109
+ * (upsert semantics), otherwise the new entry is appended.
110
+ */
111
+ addService(service: Service): Promise<WorkspaceConfig>;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Constants
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export const WORKSPACE_DIR: string;
119
+ export const WORKSPACE_FILENAME: string;
@@ -0,0 +1,448 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+ const { execFile } = require('child_process');
5
+ const { promisify } = require('util');
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const { z } = require('zod');
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const WORKSPACE_DIR = '.skyramp';
15
+ const WORKSPACE_FILENAME = 'workspace.yml';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Zod schemas
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const serviceSchema = z.object({
22
+ serviceName: z.string(),
23
+ language: z
24
+ .enum(['python', 'typescript', 'javascript', 'java'])
25
+ .optional(),
26
+ framework: z
27
+ .enum(['playwright', 'pytest', 'robot', 'junit'])
28
+ .optional(),
29
+ outputDir: z.string().optional(),
30
+ api: z
31
+ .object({
32
+ schemaPath: z.string().optional(),
33
+ authType: z
34
+ .enum(['bearer', 'basic', 'oauth', 'apiKey', 'none'])
35
+ .optional(),
36
+ authHeader: z.string().optional(),
37
+ baseUrl: z.string().optional(),
38
+ })
39
+ .strict()
40
+ .optional(),
41
+ runtimeDetails: z
42
+ .object({
43
+ serverStartCommand: z.string(),
44
+ runtime: z.enum(['local', 'docker', 'k8s']),
45
+ dockerNetwork: z.string().optional(),
46
+ k8sNamespace: z.string().optional(),
47
+ k8sContext: z.string().optional(),
48
+ })
49
+ .strict()
50
+ .optional(),
51
+ }).strict();
52
+
53
+ const workspaceConfigSchema = z.object({
54
+ workspace: z
55
+ .object({
56
+ repoName: z.string().optional(),
57
+ repoUrl: z.string().optional(),
58
+ })
59
+ .strict()
60
+ .optional(),
61
+ metadata: z
62
+ .object({
63
+ schemaVersion: z.string(),
64
+ mcpVersion: z.string(),
65
+ executorVersion: z.string(),
66
+ createdAt: z.string(),
67
+ updatedAt: z.string(),
68
+ })
69
+ .strict()
70
+ .optional(),
71
+ services: z.array(serviceSchema).optional(),
72
+ }).strict();
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Validation helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Throws a formatted Error from a failed Zod safe-parse result.
80
+ *
81
+ * @param {string} label - Human-readable context (e.g. "Workspace validation").
82
+ * @param {import('zod').SafeParseError} zodError - The `.error` from safeParse.
83
+ */
84
+ function throwValidationError(label, zodError) {
85
+ const messages = zodError.issues.map((i) => {
86
+ const pathLabel =
87
+ Array.isArray(i.path) && i.path.length > 0 ? i.path.join('.') : '<root>';
88
+ return `${pathLabel}: ${i.message}`;
89
+ });
90
+ throw new Error(`${label} failed:\n - ${messages.join('\n - ')}`);
91
+ }
92
+
93
+ /**
94
+ * Validates a workspace configuration object against the Zod schema.
95
+ *
96
+ * @param {unknown} config - The configuration to validate.
97
+ * @returns {import('zod').SafeParseReturnType} Zod safe-parse result.
98
+ */
99
+ function validateWorkspaceConfig(config) {
100
+ return workspaceConfigSchema.safeParse(config);
101
+ }
102
+
103
+ /**
104
+ * Validates and returns the parsed data, or throws on failure.
105
+ *
106
+ * @param {unknown} config - The configuration to validate.
107
+ * @param {string} [label='Workspace validation'] - Error label.
108
+ * @returns {Object} The validated config data.
109
+ */
110
+ function validateOrThrow(config, label = 'Workspace validation') {
111
+ const result = validateWorkspaceConfig(config);
112
+ if (!result.success) {
113
+ throwValidationError(label, result.error);
114
+ }
115
+ return result.data;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Defaults
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Creates a default workspace config with empty sections and timestamps.
124
+ *
125
+ * @returns {Object} Default workspace config.
126
+ */
127
+ function createDefaultConfig() {
128
+ const now = new Date().toISOString();
129
+ return {
130
+ workspace: {},
131
+ metadata: {
132
+ schemaVersion: 'v1',
133
+ mcpVersion: '',
134
+ executorVersion: '',
135
+ createdAt: now,
136
+ updatedAt: now,
137
+ },
138
+ services: [],
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Low-level I/O helpers
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Writes a workspace config to disk as YAML.
148
+ *
149
+ * @param {string} filePath - Absolute path to write to.
150
+ * @param {Object} config - The workspace config object.
151
+ * @returns {Promise<void>}
152
+ */
153
+ async function writeWorkspaceFile(filePath, config) {
154
+ const header = '# Skyramp Workspace Configuration\n';
155
+ const body = yaml.dump(config, {
156
+ lineWidth: 120,
157
+ noRefs: true,
158
+ quotingType: '"',
159
+ forceQuotes: false,
160
+ });
161
+
162
+ try {
163
+ await fs.writeFile(filePath, header + body, 'utf8');
164
+ } catch (err) {
165
+ throw new Error(
166
+ `Failed to write workspace file to ${filePath}: ${err.message}`
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Auto-detects repo name and URL from git.
173
+ * Throws an error if the directory is not a git repository.
174
+ *
175
+ * @param {string} [dirPath=process.cwd()]
176
+ * @param {boolean} [requireGit=true] - If true, throws error when not a git repo
177
+ * @returns {Promise<{ repoName: string|null, repoUrl: string|null }>}
178
+ */
179
+ async function detectRepoInfo(dirPath, requireGit = true) {
180
+ const cwd = dirPath ? path.resolve(dirPath) : process.cwd();
181
+ let repoUrl = null;
182
+ let repoName = null;
183
+ let isGitRepo = false;
184
+
185
+ // First, check if this is a git repository
186
+ try {
187
+ await execFileAsync('git', ['rev-parse', '--git-dir'], {
188
+ cwd,
189
+ timeout: 5000,
190
+ });
191
+ isGitRepo = true;
192
+ } catch (err) {
193
+ if (requireGit) {
194
+ throw new Error(
195
+ `Workspace initialization requires a git repository. Directory '${cwd}' is not a git repository. ` +
196
+ `We cannot initialize the workspace in a non-git repository.`
197
+ );
198
+ }
199
+ }
200
+
201
+ // If it's a git repo, try to get the remote URL
202
+ if (isGitRepo) {
203
+ try {
204
+ const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
205
+ cwd,
206
+ timeout: 5000,
207
+ });
208
+ repoUrl = stdout.trim();
209
+ } catch {
210
+ // Git repo exists but no remote configured - this is OK
211
+ }
212
+
213
+ if (repoUrl) {
214
+ const match = repoUrl.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
215
+ if (match) repoName = match[1];
216
+ }
217
+
218
+ if (!repoName) {
219
+ repoName = path.basename(cwd);
220
+ }
221
+ }
222
+
223
+ return { repoName, repoUrl };
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Low-level state management (standalone, path-based API)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Loads and validates a workspace YAML file from disk.
232
+ *
233
+ * @param {string} filePath - Absolute path to the workspace file.
234
+ * @returns {Promise<{ config: Object, filePath: string }>}
235
+ */
236
+ async function loadWorkspaceFile(filePath) {
237
+ const resolvedPath = path.resolve(filePath);
238
+
239
+ try {
240
+ await fs.access(resolvedPath);
241
+ } catch {
242
+ throw new Error(`Workspace file not found: ${resolvedPath}`);
243
+ }
244
+
245
+ const content = await fs.readFile(resolvedPath, 'utf8');
246
+ let rawConfig;
247
+ try {
248
+ // Use JSON_SCHEMA to prevent timestamp auto-conversion to Date objects
249
+ rawConfig = yaml.load(content, { schema: yaml.JSON_SCHEMA });
250
+ } catch (err) {
251
+ throw new Error(`Failed to parse workspace YAML: ${err.message}`);
252
+ }
253
+
254
+ const validated = validateOrThrow(rawConfig);
255
+ return { config: validated, filePath: resolvedPath };
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // WorkspaceConfigManager
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * High-level manager for .skyramp/workspace.yml.
264
+ *
265
+ * Provides three focused mutation methods:
266
+ * 1. initialize() — create workspace file with repo info
267
+ * 2. updateMetadata() — update metadata section
268
+ * 3. addService() — upsert a single service entry
269
+ *
270
+ * @param {string} workspacePath - Repository / project root.
271
+ */
272
+ class WorkspaceConfigManager {
273
+ constructor(workspacePath) {
274
+ this.workspacePath = path.resolve(workspacePath);
275
+ this.skyrampDir = path.join(this.workspacePath, WORKSPACE_DIR);
276
+ this.configPath = path.join(this.skyrampDir, WORKSPACE_FILENAME);
277
+ }
278
+
279
+ /** Check if workspace config file exists */
280
+ async exists() {
281
+ try {
282
+ await fs.access(this.configPath);
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ /** Get the absolute path to the config file */
290
+ getConfigPath() {
291
+ return this.configPath;
292
+ }
293
+
294
+ /** Get the workspace root path */
295
+ getWorkspacePath() {
296
+ return this.workspacePath;
297
+ }
298
+
299
+ /**
300
+ * Read and parse workspace config from disk.
301
+ *
302
+ * @returns {Promise<Object>} The validated WorkspaceConfig.
303
+ */
304
+ async read() {
305
+ if (!(await this.exists())) {
306
+ throw new Error(
307
+ `Workspace config not found at ${this.configPath}. Initialize the workspace first.`,
308
+ );
309
+ }
310
+ const result = await loadWorkspaceFile(this.configPath);
311
+ return result.config;
312
+ }
313
+
314
+ // -----------------------------------------------------------------------
315
+ // 1. Initialize workspace with repo info
316
+ // -----------------------------------------------------------------------
317
+
318
+ /**
319
+ * Initialize workspace — creates .skyramp/workspace.yml with repo info.
320
+ * Auto-detects repoName and repoUrl from git when not provided.
321
+ * Produces an empty services array and default metadata timestamps.
322
+ *
323
+ * @param {Object} [workspaceInfo] - Optional explicit repo info.
324
+ * @param {string} [workspaceInfo.repoName] - Repository name.
325
+ * @param {string} [workspaceInfo.repoUrl] - Repository URL.
326
+ * @returns {Promise<Object>} The validated WorkspaceConfig.
327
+ */
328
+ async initialize(workspaceInfo = {}) {
329
+ await fs.mkdir(this.skyrampDir, { recursive: true });
330
+
331
+ // Auto-detect repo info from workspace root when not explicitly provided
332
+ const detected = await detectRepoInfo(this.workspacePath);
333
+
334
+ const config = createDefaultConfig();
335
+ config.workspace = {
336
+ repoName: workspaceInfo.repoName || detected.repoName || undefined,
337
+ repoUrl: workspaceInfo.repoUrl || detected.repoUrl || undefined,
338
+ };
339
+
340
+ const validated = validateOrThrow(config);
341
+ await writeWorkspaceFile(this.configPath, validated);
342
+ return validated;
343
+ }
344
+
345
+ // -----------------------------------------------------------------------
346
+ // 2. Update metadata
347
+ // -----------------------------------------------------------------------
348
+
349
+ /**
350
+ * Update the metadata section of an existing workspace config.
351
+ * Only the provided fields are merged; others are left untouched.
352
+ * metadata.updatedAt is refreshed automatically.
353
+ *
354
+ * @param {Object} metadata - Partial metadata fields to merge.
355
+ * @param {string} [metadata.schemaVersion]
356
+ * @param {string} [metadata.mcpVersion]
357
+ * @param {string} [metadata.executorVersion]
358
+ * @returns {Promise<Object>} The updated, validated WorkspaceConfig.
359
+ */
360
+ async updateMetadata(metadata) {
361
+ if (!metadata || typeof metadata !== 'object') {
362
+ throw new Error('metadata must be a non-null object');
363
+ }
364
+
365
+ const config = await this.read();
366
+ const existingMetadata = config.metadata || createDefaultConfig().metadata;
367
+
368
+ config.metadata = {
369
+ ...existingMetadata,
370
+ ...metadata,
371
+ createdAt: existingMetadata.createdAt, // Preserve original createdAt
372
+ updatedAt: new Date().toISOString(),
373
+ };
374
+
375
+ const validated = validateOrThrow(config);
376
+ await writeWorkspaceFile(this.configPath, validated);
377
+ return validated;
378
+ }
379
+
380
+ // -----------------------------------------------------------------------
381
+ // 3. Add / upsert a single service
382
+ // -----------------------------------------------------------------------
383
+
384
+ /**
385
+ * Add a single service entry to the services array.
386
+ * If a service with the same serviceName already exists it is replaced
387
+ * (upsert semantics), otherwise the new entry is appended.
388
+ * metadata.updatedAt is refreshed automatically.
389
+ *
390
+ * @param {Object} service - A service object matching the serviceSchema.
391
+ * @param {string} service.serviceName - Unique service identifier (required).
392
+ * @returns {Promise<Object>} The updated, validated WorkspaceConfig.
393
+ */
394
+ async addService(service) {
395
+ if (!service || typeof service !== 'object') {
396
+ throw new Error('service must be a non-null object');
397
+ }
398
+
399
+ // Validate the individual service entry first
400
+ const svcResult = serviceSchema.safeParse(service);
401
+ if (!svcResult.success) {
402
+ throwValidationError('Service validation', svcResult.error);
403
+ }
404
+
405
+ const config = await this.read();
406
+ const services = config.services || [];
407
+
408
+ // Upsert: replace existing entry with same serviceName, or append
409
+ const idx = services.findIndex(
410
+ (s) => s.serviceName === svcResult.data.serviceName,
411
+ );
412
+ if (idx >= 0) {
413
+ services[idx] = svcResult.data;
414
+ } else {
415
+ services.push(svcResult.data);
416
+ }
417
+ config.services = services;
418
+
419
+ // Refresh timestamp
420
+ if (!config.metadata) {
421
+ config.metadata = createDefaultConfig().metadata;
422
+ }
423
+ config.metadata.updatedAt = new Date().toISOString();
424
+
425
+ const validated = validateOrThrow(config);
426
+ await writeWorkspaceFile(this.configPath, validated);
427
+ return validated;
428
+ }
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Exports
433
+ // ---------------------------------------------------------------------------
434
+
435
+ module.exports = {
436
+ // Schema
437
+ workspaceConfigSchema,
438
+ serviceSchema,
439
+ // High-level manager
440
+ WorkspaceConfigManager,
441
+ // Validation
442
+ validateWorkspaceConfig,
443
+ // Helpers
444
+ createDefaultConfig,
445
+ // Constants
446
+ WORKSPACE_DIR,
447
+ WORKSPACE_FILENAME,
448
+ };