diff --git a/doc/apis.md b/doc/apis.md index fa0da414d..de7be066e 100644 --- a/doc/apis.md +++ b/doc/apis.md @@ -27,7 +27,7 @@ | Delete migrated data set | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | Rename data set | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Rename data set member | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Copy data set | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Copy data set | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | | Compress data set | ➖ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | Search data sets | 🚧 4 | ❌ | 🚧 4 | ❌ | ✅ | ❌ | ❌ | | Invoke AMS (VSAM) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ➖ | diff --git a/native/CHANGELOG.md b/native/CHANGELOG.md index 82126d584..eed939c55 100644 --- a/native/CHANGELOG.md +++ b/native/CHANGELOG.md @@ -4,8 +4,9 @@ All notable changes to the native code for "zowe-native-proto" are documented in Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. -## `0.4.0` +## Recent Changes +- `c`: Exposed `copyDataset` over JSON-RPC with the same arguments as `zowex data-set copy` (`fromDataset` / `toDataset` map to `source` / `target`). Copy execution remains the existing `zds_copy_dsn` implementation; response includes `success` and optional `targetCreated` / `memberCreated`. - `c`: Added `zowex uss issue` command and the `unixCommand` RPC for executing USS shell commands using `spawn()` with `_BPX_SHAREAS=YES` for efficient same-address-space execution. [#867](https://github.com/zowe/zowe-native-proto/pull/867) - `c`: Fixed an issue where submitting JCL from the VS Code editor with CRLF line endings caused job submission errors. [#882](https://github.com/zowe/zowe-native-proto/pull/882) - `c`: Fixed an issue where VSAM index or data components returned `*VSAM*` for the volume serial. Now, an accurate volume serial is returned for both component types. [#864](https://github.com/zowe/zowe-native-proto/pull/864) diff --git a/native/c/commands/ds.cpp b/native/c/commands/ds.cpp index 33c6a8cad..f350de5b2 100644 --- a/native/c/commands/ds.cpp +++ b/native/c/commands/ds.cpp @@ -980,12 +980,16 @@ int handle_data_set_copy(InvocationContext &context) return RTNCD_FAILURE; } + const auto result = obj(); + result->set("success", boolean(true)); if (options.target_created) { + result->set("targetCreated", boolean(true)); context.output_stream() << "New data set '" << target << "' created and copied from '" << source << "'" << std::endl; } else if (options.member_created) { + result->set("memberCreated", boolean(true)); context.output_stream() << "New member '" << target << "' created and copied from '" << source << "'" << std::endl; } else if (options.delete_target_members) @@ -1000,6 +1004,7 @@ int handle_data_set_copy(InvocationContext &context) { context.output_stream() << "Data set '" << source << "' copied to '" << target << "'" << std::endl; } + context.set_object(result); return RTNCD_SUCCESS; } diff --git a/native/c/commands/ds.hpp b/native/c/commands/ds.hpp index c7abbb272..4bc07c766 100644 --- a/native/c/commands/ds.hpp +++ b/native/c/commands/ds.hpp @@ -29,5 +29,6 @@ int handle_data_set_compress(InvocationContext &result); int handle_data_set_create_member(InvocationContext &result); int handle_data_set_rename(InvocationContext &result); int handle_rename_member(InvocationContext &result); +int handle_data_set_copy(InvocationContext &result); void register_commands(parser::Command &root_command); } // namespace ds \ No newline at end of file diff --git a/native/c/server/rpc_commands.cpp b/native/c/server/rpc_commands.cpp index 48c2d4ce1..2eb768055 100644 --- a/native/c/server/rpc_commands.cpp +++ b/native/c/server/rpc_commands.cpp @@ -84,6 +84,12 @@ void register_ds_commands(CommandDispatcher &dispatcher) .handle_fifo("stream", "pipe-path", FifoMode::PUT)); dispatcher.register_command("renameDataset", create_ds_builder(ds::handle_data_set_rename).validate()); dispatcher.register_command("renameMember", create_ds_builder(ds::handle_rename_member).validate()); + dispatcher.register_command("copyDataset", + CommandBuilder(ds::handle_data_set_copy) + .validate() + .rename_arg("fromDataset", "source") + .rename_arg("toDataset", "target") + .rename_arg("deleteTargetMembers", "delete-target-members")); } void register_job_commands(CommandDispatcher &dispatcher) diff --git a/native/c/server/schemas/requests.hpp b/native/c/server/schemas/requests.hpp index f3bb186bf..cfa1cf5b8 100644 --- a/native/c/server/schemas/requests.hpp +++ b/native/c/server/schemas/requests.hpp @@ -93,11 +93,11 @@ ZJSON_SCHEMA(ListDsMembersRequest, struct ReadDatasetRequest {}; ZJSON_SCHEMA(ReadDatasetRequest, - FIELD_OPTIONAL(stream, ANY), FIELD_OPTIONAL(encoding, STRING), FIELD_OPTIONAL(localEncoding, STRING), FIELD_OPTIONAL(volume, STRING), - FIELD_REQUIRED(dsname, STRING) + FIELD_REQUIRED(dsname, STRING), + FIELD_OPTIONAL(stream, ANY) ); struct RestoreDatasetRequest {}; @@ -107,13 +107,21 @@ ZJSON_SCHEMA(RestoreDatasetRequest, struct WriteDatasetRequest {}; ZJSON_SCHEMA(WriteDatasetRequest, - FIELD_OPTIONAL(stream, ANY), FIELD_OPTIONAL(encoding, STRING), FIELD_OPTIONAL(localEncoding, STRING), FIELD_OPTIONAL(etag, STRING), FIELD_OPTIONAL(volume, STRING), FIELD_REQUIRED(dsname, STRING), - FIELD_OPTIONAL(data, STRING) + FIELD_OPTIONAL(data, STRING), + FIELD_OPTIONAL(stream, ANY) +); + +struct CopyDatasetRequest {}; +ZJSON_SCHEMA(CopyDatasetRequest, + FIELD_REQUIRED(fromDataset, STRING), + FIELD_REQUIRED(toDataset, STRING), + FIELD_OPTIONAL(replace, BOOL), + FIELD_OPTIONAL(deleteTargetMembers, BOOL) ); struct CancelJobRequest {}; @@ -253,20 +261,20 @@ ZJSON_SCHEMA(ListFilesRequest, struct ReadFileRequest {}; ZJSON_SCHEMA(ReadFileRequest, - FIELD_OPTIONAL(stream, ANY), FIELD_OPTIONAL(encoding, STRING), FIELD_OPTIONAL(localEncoding, STRING), - FIELD_REQUIRED(fspath, STRING) + FIELD_REQUIRED(fspath, STRING), + FIELD_OPTIONAL(stream, ANY) ); struct WriteFileRequest {}; ZJSON_SCHEMA(WriteFileRequest, - FIELD_OPTIONAL(stream, ANY), FIELD_OPTIONAL(encoding, STRING), FIELD_OPTIONAL(localEncoding, STRING), FIELD_OPTIONAL(etag, STRING), FIELD_REQUIRED(fspath, STRING), FIELD_OPTIONAL(data, STRING), + FIELD_OPTIONAL(stream, ANY), FIELD_OPTIONAL(contentLen, NUMBER) ); diff --git a/native/c/server/schemas/responses.hpp b/native/c/server/schemas/responses.hpp index 8a4f48c28..d651af68c 100644 --- a/native/c/server/schemas/responses.hpp +++ b/native/c/server/schemas/responses.hpp @@ -163,6 +163,13 @@ ZJSON_SCHEMA(WriteDatasetResponse, FIELD_OPTIONAL(truncationWarning, STRING) ); +struct CopyDatasetResponse {}; +ZJSON_SCHEMA(CopyDatasetResponse, + FIELD_REQUIRED(success, BOOL), + FIELD_OPTIONAL(targetCreated, BOOL), + FIELD_OPTIONAL(memberCreated, BOOL) +); + struct CancelJobResponse {}; ZJSON_SCHEMA(CancelJobResponse, FIELD_REQUIRED(success, BOOL) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index acf6dfa1c..6c631b063 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -4,8 +4,10 @@ All notable changes to the Client code for "zowe-native-proto-cli" are documente Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. -## `0.4.0` +## Recent Changes +- Added `zssh copy data-set` command to copy data sets and members with optional `--replace` and `--delete-target-members`. Supports PDS-to-PDS, member-to-member, and sequential-to-sequential copies. Note: RECFM=U data sets are not supported. [#778](https://github.com/zowe/zowe-native-proto/pull/778) +## `0.4.0` - Added an `--attributes` flag to list ISPF statistics for member attributes. [#630](https://github.com/zowe/zowe-native-proto/issues/630) - Added the `zssh uss copy` command to the CLI. [#379](https://github.com/zowe/zowe-native-proto/pull/379). - Updated `stream` fields to match new requirements from the SDK. [#548](https://github.com/zowe/zowe-native-proto/issues/548) diff --git a/packages/cli/src/copy/Copy.definition.ts b/packages/cli/src/copy/Copy.definition.ts new file mode 100644 index 000000000..ef5a57916 --- /dev/null +++ b/packages/cli/src/copy/Copy.definition.ts @@ -0,0 +1,34 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import type { ICommandDefinition } from "@zowe/imperative"; +import { SshSession } from "@zowe/zos-uss-for-zowe-sdk"; +import { Constants } from "../Constants"; +import { CopyDataSetDefinition } from "./data-set/DataSet.definition"; + +const CopyDefinition: ICommandDefinition = { + name: "copy", + aliases: ["cp"], + summary: "Copy data sets and members", + description: "Copy a data set or member to another data set or member", + type: "group", + children: [CopyDataSetDefinition], + passOn: [ + { + property: "options", + value: [...SshSession.SSH_CONNECTION_OPTIONS, Constants.OPT_SERVER_PATH], + merge: true, + ignoreNodes: [{ type: "group" }], + }, + ], +}; + +export = CopyDefinition; diff --git a/packages/cli/src/copy/data-set/DataSet.definition.ts b/packages/cli/src/copy/data-set/DataSet.definition.ts new file mode 100644 index 000000000..83dcdd33e --- /dev/null +++ b/packages/cli/src/copy/data-set/DataSet.definition.ts @@ -0,0 +1,81 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import type { ICommandDefinition } from "@zowe/imperative"; + +export const CopyDataSetDefinition: ICommandDefinition = { + handler: `${__dirname}/DataSet.handler`, + description: + "Copy a data set or member to another data set or member. " + + "Supports PDS-to-PDS, member-to-member, and sequential-to-sequential copies. " + + "Note: RECFM=U data sets are not supported.", + type: "command", + name: "data-set", + aliases: ["ds"], + summary: "Copy a data set", + examples: [ + { + description: "Copy a sequential data set to a new sequential data set", + options: '"ibmuser.source.seq" "ibmuser.target.seq"', + }, + { + description: "Copy a PDS to a new PDS (copies all members)", + options: '"ibmuser.source.pds" "ibmuser.target.pds"', + }, + { + description: "Copy a single member to another PDS", + options: '"ibmuser.source.pds(member)" "ibmuser.target.pds(member)"', + }, + { + description: "Copy a PDS and replace existing members in the target", + options: '"ibmuser.source.pds" "ibmuser.target.pds" --replace', + }, + { + description: "Copy a PDS and delete all target members before copying (makes target match source exactly)", + options: '"ibmuser.source.pds" "ibmuser.target.pds" --delete-target-members', + }, + ], + positionals: [ + { + name: "fromDataset", + description: "The source data set to copy from (can include member name in parentheses)", + type: "string", + required: true, + }, + { + name: "toDataset", + description: "The target data set to copy to (can include member name in parentheses)", + type: "string", + required: true, + }, + ], + options: [ + { + name: "replace", + aliases: ["r"], + description: + "Replace existing data. For PDS-to-PDS: replaces matching members, preserves target-only members. " + + "For sequential or member-to-member: overwrites the target.", + type: "boolean", + defaultValue: false, + }, + { + name: "delete-target-members", + aliases: ["d"], + description: + "Delete all members from target PDS before copying (PDS-to-PDS copy only). " + + "Makes the target match the source exactly.", + type: "boolean", + defaultValue: false, + }, + ], + profile: { optional: ["ssh"] }, +}; diff --git a/packages/cli/src/copy/data-set/DataSet.handler.ts b/packages/cli/src/copy/data-set/DataSet.handler.ts new file mode 100644 index 000000000..1b212e7cb --- /dev/null +++ b/packages/cli/src/copy/data-set/DataSet.handler.ts @@ -0,0 +1,54 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import type { IHandlerParameters } from "@zowe/imperative"; +import type { ds, ZSshClient } from "zowe-native-proto-sdk"; +import { SshBaseHandler } from "../../SshBaseHandler"; + +export default class CopyDataSetHandler extends SshBaseHandler { + public async processWithClient(params: IHandlerParameters, client: ZSshClient): Promise { + const fromDataset = params.arguments.fromDataset; + const toDataset = params.arguments.toDataset; + const replace = params.arguments.replace ?? false; + const deleteTargetMembers = params.arguments.deleteTargetMembers ?? false; + + const response = await client.ds.copyDataset({ fromDataset, toDataset, replace, deleteTargetMembers }); + + let dsMessage: string; + if (response.success) { + if (response.targetCreated) { + dsMessage = `Data set "${toDataset}" created and copied from "${fromDataset}"`; + } else if (deleteTargetMembers) { + dsMessage = `Target members deleted and data set "${toDataset}" replaced with contents of "${fromDataset}"`; + } else if (replace) { + dsMessage = `Data set "${toDataset}" updated with contents of "${fromDataset}"`; + } else { + dsMessage = `Data set "${fromDataset}" copied to "${toDataset}"`; + } + } else { + const r = response as ds.CopyDatasetResponse & { stderr?: string; message?: string }; + const detail = r.stderr?.trim() || r.message?.trim(); + dsMessage = detail + ? `Copy failed: "${fromDataset}" to "${toDataset}": ${detail}` + : `Copy failed: "${fromDataset}" to "${toDataset}"`; + } + + params.response.data.setMessage(dsMessage); + params.response.data.setObj(response); + if (response.success) { + params.response.console.log(dsMessage); + } else { + params.response.console.error(dsMessage); + params.response.data.setExitCode(1); + } + return response; + } +} diff --git a/packages/cli/tests/copy/DataSet.handler.test.ts b/packages/cli/tests/copy/DataSet.handler.test.ts new file mode 100644 index 000000000..0733c71bd --- /dev/null +++ b/packages/cli/tests/copy/DataSet.handler.test.ts @@ -0,0 +1,214 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import type { IHandlerParameters } from "@zowe/imperative"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ds, ZSshClient } from "zowe-native-proto-sdk"; +import CopyDataSetHandler from "../../src/copy/data-set/DataSet.handler"; + +describe("CopyDataSetHandler", () => { + let handler: CopyDataSetHandler; + let mockClient: ZSshClient; + let mockParams: IHandlerParameters; + let mockCopyDataset: ReturnType; + + beforeEach(() => { + handler = new CopyDataSetHandler(); + + mockCopyDataset = vi.fn(); + mockClient = { + ds: { + copyDataset: mockCopyDataset, + }, + } as unknown as ZSshClient; + + mockParams = { + arguments: { + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + }, + response: { + data: { + setMessage: vi.fn(), + setObj: vi.fn(), + setExitCode: vi.fn(), + }, + console: { + log: vi.fn(), + error: vi.fn(), + }, + }, + } as unknown as IHandlerParameters; + }); + + describe("processWithClient", () => { + it("should copy a data set successfully", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + const result = await handler.processWithClient(mockParams, mockClient); + + expect(mockCopyDataset).toHaveBeenCalledWith({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + replace: false, + deleteTargetMembers: false, + }); + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Data set "SOURCE.DATA.SET" copied to "TARGET.DATA.SET"', + ); + expect(mockParams.response.console.log).toHaveBeenCalledWith( + 'Data set "SOURCE.DATA.SET" copied to "TARGET.DATA.SET"', + ); + expect(result).toEqual(mockResponse); + }); + + it("should display message when target is created", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + targetCreated: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Data set "TARGET.DATA.SET" created and copied from "SOURCE.DATA.SET"', + ); + }); + + it("should display message when deleteTargetMembers is used", async () => { + mockParams.arguments.deleteTargetMembers = true; + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockCopyDataset).toHaveBeenCalledWith({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + replace: false, + deleteTargetMembers: true, + }); + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Target members deleted and data set "TARGET.DATA.SET" replaced with contents of "SOURCE.DATA.SET"', + ); + }); + + it("should display message when replace is used", async () => { + mockParams.arguments.replace = true; + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockCopyDataset).toHaveBeenCalledWith({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + replace: true, + deleteTargetMembers: false, + }); + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Data set "TARGET.DATA.SET" updated with contents of "SOURCE.DATA.SET"', + ); + }); + + it("should prioritize targetCreated message over deleteTargetMembers", async () => { + mockParams.arguments.deleteTargetMembers = true; + const mockResponse: ds.CopyDatasetResponse = { + success: true, + targetCreated: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Data set "TARGET.DATA.SET" created and copied from "SOURCE.DATA.SET"', + ); + }); + + it("should copy a member to another member", async () => { + mockParams.arguments.fromDataset = "SOURCE.PDS(MEMBER1)"; + mockParams.arguments.toDataset = "TARGET.PDS(MEMBER2)"; + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockCopyDataset).toHaveBeenCalledWith({ + fromDataset: "SOURCE.PDS(MEMBER1)", + toDataset: "TARGET.PDS(MEMBER2)", + replace: false, + deleteTargetMembers: false, + }); + }); + + it("should not log to console when response is unsuccessful", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: false, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + const expected = 'Copy failed: "SOURCE.DATA.SET" to "TARGET.DATA.SET"'; + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith(expected); + expect(mockParams.response.console.log).not.toHaveBeenCalled(); + expect(mockParams.response.console.error).toHaveBeenCalledWith(expected); + expect(mockParams.response.data.setExitCode).toHaveBeenCalledWith(1); + }); + + it("should append stderr or message when copy response is unsuccessful", async () => { + const mockResponse = { + success: false, + stderr: " zowex: allocation failed ", + } as ds.CopyDatasetResponse; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Copy failed: "SOURCE.DATA.SET" to "TARGET.DATA.SET": zowex: allocation failed', + ); + }); + + it("should handle both replace and deleteTargetMembers options", async () => { + mockParams.arguments.replace = true; + mockParams.arguments.deleteTargetMembers = true; + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + mockCopyDataset.mockResolvedValue(mockResponse); + + await handler.processWithClient(mockParams, mockClient); + + expect(mockCopyDataset).toHaveBeenCalledWith({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + replace: true, + deleteTargetMembers: true, + }); + // deleteTargetMembers takes precedence in the message + expect(mockParams.response.data.setMessage).toHaveBeenCalledWith( + 'Target members deleted and data set "TARGET.DATA.SET" replaced with contents of "SOURCE.DATA.SET"', + ); + }); + }); +}); diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 999f5bd04..634239997 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the Client code for "zowe-native-proto-sdk" are documente Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. ## Recent Changes - +- Added `copyDataset` to `RpcClientApi.ds`, matching the `zowex data-set copy` / server `copyDataset` RPC shape (`fromDataset`, `toDataset`, `replace`, `deleteTargetMembers`). [#778](https://github.com/zowe/zowe-native-proto/pull/778) - Added warning to `AbstractConfigManager.validateDeployPath` method when server path ends in `/c/build-out`, preventing developers from accidentally overwriting a dev deployment. [#912](https://github.com/zowe/zowe-native-proto/pull/912) ## `0.4.0` diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b900f2990..f7dd3fcf7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -39,10 +39,10 @@ "typescript": "^5.7.3" }, "optionalDependencies": { - "russh": "0.1.36" + "russh": "^0.1.35" }, "peerDependencies": { "@zowe/imperative": "^8.11.0", "@zowe/zos-uss-for-zowe-sdk": "^8.11.0" } -} \ No newline at end of file +} diff --git a/packages/sdk/src/RpcClientApi.ts b/packages/sdk/src/RpcClientApi.ts index fd3a48fef..eb5d7c245 100644 --- a/packages/sdk/src/RpcClientApi.ts +++ b/packages/sdk/src/RpcClientApi.ts @@ -24,6 +24,7 @@ export abstract class RpcClientApi implements IRpcClient { }; public ds = { + copyDataset: this.rpc("copyDataset"), createDataset: this.rpc("createDataset"), createMember: this.rpc("createMember"), deleteDataset: this.rpc("deleteDataset"), diff --git a/packages/sdk/src/doc/rpc/ds.ts b/packages/sdk/src/doc/rpc/ds.ts index 6d0f931f9..f1050c822 100644 --- a/packages/sdk/src/doc/rpc/ds.ts +++ b/packages/sdk/src/doc/rpc/ds.ts @@ -227,3 +227,40 @@ export interface WriteDatasetResponse extends common.CommandResponse { */ truncationWarning?: string; } + +/** + * JSON-RPC `copyDataset`: same parameters the zowex CLI maps from `data-set copy` (`source` / `target` / flags). + * Server-side copy behavior (including member ISPF statistics) may evolve without changing this contract. + */ +export interface CopyDatasetRequest extends common.CommandRequest<"copyDataset"> { + /** + * Source data set name (can include member in parentheses) + */ + fromDataset: string; + /** + * Target data set name (can include member in parentheses) + */ + toDataset: string; + /** + * Replace existing data. + * For PDS-to-PDS: replaces matching members, preserves target-only members. + * For sequential or member-to-member: overwrites the target. + */ + replace?: boolean; + /** + * Delete all members from target PDS before copying (PDS-to-PDS copy only). + * Makes the target match the source exactly. + */ + deleteTargetMembers?: boolean; +} + +export interface CopyDatasetResponse extends common.CommandResponse { + /** + * True if a new target data set was created + */ + targetCreated?: boolean; + /** + * True if a new target member was created + */ + memberCreated?: boolean; +} diff --git a/packages/sdk/tests/RpcClientApi.test.ts b/packages/sdk/tests/RpcClientApi.test.ts new file mode 100644 index 000000000..7e50b3ed0 --- /dev/null +++ b/packages/sdk/tests/RpcClientApi.test.ts @@ -0,0 +1,151 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ds } from "../src/doc/rpc"; +import { RpcClientApi } from "../src/RpcClientApi"; + +// Create a concrete implementation for testing +class TestRpcClient extends RpcClientApi { + public mockRequest = vi.fn(); + + public request(request: ReqT): Promise { + return this.mockRequest(request); + } +} + +describe("RpcClientApi", () => { + let client: TestRpcClient; + + beforeEach(() => { + client = new TestRpcClient(); + }); + + describe("ds.copyDataset", () => { + it("should call request with correct command and parameters", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + client.mockRequest.mockResolvedValue(mockResponse); + + const result = await client.ds.copyDataset({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + }); + + expect(client.mockRequest).toHaveBeenCalledWith({ + command: "copyDataset", + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + }); + expect(result).toEqual(mockResponse); + }); + + it("should pass replace option correctly", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + client.mockRequest.mockResolvedValue(mockResponse); + + await client.ds.copyDataset({ + fromDataset: "SOURCE.PDS", + toDataset: "TARGET.PDS", + replace: true, + }); + + expect(client.mockRequest).toHaveBeenCalledWith({ + command: "copyDataset", + fromDataset: "SOURCE.PDS", + toDataset: "TARGET.PDS", + replace: true, + }); + }); + + it("should pass deleteTargetMembers option correctly", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + client.mockRequest.mockResolvedValue(mockResponse); + + await client.ds.copyDataset({ + fromDataset: "SOURCE.PDS", + toDataset: "TARGET.PDS", + deleteTargetMembers: true, + }); + + expect(client.mockRequest).toHaveBeenCalledWith({ + command: "copyDataset", + fromDataset: "SOURCE.PDS", + toDataset: "TARGET.PDS", + deleteTargetMembers: true, + }); + }); + + it("should handle member copies", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + }; + client.mockRequest.mockResolvedValue(mockResponse); + + await client.ds.copyDataset({ + fromDataset: "SOURCE.PDS(MEMBER)", + toDataset: "TARGET.PDS(MEMBER)", + }); + + expect(client.mockRequest).toHaveBeenCalledWith({ + command: "copyDataset", + fromDataset: "SOURCE.PDS(MEMBER)", + toDataset: "TARGET.PDS(MEMBER)", + }); + }); + + it("should return targetCreated when target was created", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: true, + targetCreated: true, + }; + client.mockRequest.mockResolvedValue(mockResponse); + + const result = await client.ds.copyDataset({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + }); + + expect(result.targetCreated).toBe(true); + }); + + it("should handle failure response", async () => { + const mockResponse: ds.CopyDatasetResponse = { + success: false, + }; + client.mockRequest.mockResolvedValue(mockResponse); + + const result = await client.ds.copyDataset({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + }); + + expect(result.success).toBe(false); + }); + + it("should propagate errors from request", async () => { + const testError = new Error("Copy failed"); + client.mockRequest.mockRejectedValue(testError); + + await expect( + client.ds.copyDataset({ + fromDataset: "SOURCE.DATA.SET", + toDataset: "TARGET.DATA.SET", + }), + ).rejects.toThrow("Copy failed"); + }); + }); +}); diff --git a/packages/vsce/CHANGELOG.md b/packages/vsce/CHANGELOG.md index 7f928245c..17bf0c203 100644 --- a/packages/vsce/CHANGELOG.md +++ b/packages/vsce/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the "zowe-native-proto-vsce" extension will be documented Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## Recent Changes + +- Enhanced copy functionality with `copyDataSetMember` for member-to-member and full data set copies supporting `replace` and `deleteTargetMembers` options, plus `copyDataSet` wrapper for full data set operations. When the target data set does not exist, the server allocates it like the source then copies data. [#778](https://github.com/zowe/zowe-native-proto/pull/778) + ## `0.4.0` - Added error correlation for expired z/OS password (`FOTS1668`/`FOTS1669`), surfacing actionable tips and documentation links in Zowe Explorer when SSH commands fail due to an expired password. [#867](https://github.com/zowe/zowe-native-proto/pull/867) @@ -21,7 +25,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## `0.2.4` -- Added the functionality for the **Rename Member** option. [#765] (https://github.com/zowe/zowe-native-proto/pull/765). +- Added the functionality for the **Rename Member** option. [#765](https://github.com/zowe/zowe-native-proto/pull/765). - Added the `multivolume` (`mvol`) property when displaying data set attributes. [#782](https://github.com/zowe/zowe-native-proto/pull/782) - Fixed an issue where using the "Upload Member" option with an SSH profile in Zowe Explorer caused an error. Now, the member name is provided to the back end for each member that is uploaded. [#785](https://github.com/zowe/zowe-native-proto/issues/785) diff --git a/packages/vsce/src/api/SshMvsApi.ts b/packages/vsce/src/api/SshMvsApi.ts index ac904c9ad..0c2535395 100644 --- a/packages/vsce/src/api/SshMvsApi.ts +++ b/packages/vsce/src/api/SshMvsApi.ts @@ -311,19 +311,109 @@ export class SshMvsApi extends SshCommonApi implements MainframeInteraction.IMvs return this.buildZosFilesResponse(response, response.success); } + /** + * Zowe Explorer “allocate like” step before copy: validates the model data set exists and is not RECFM=U. + * Does not allocate; actual allocation happens on the server during `copyDataset` when the target is new. + * + * @param _targetDataSetName Target name Explorer passes for the next step; unused here. + * @param likeDataSetName Model data set name to validate + */ public async allocateLikeDataSet( - _dataSetName: string, - _likeDataSetName: string, + _targetDataSetName: string, + likeDataSetName: string, ): Promise { - throw new Error("Not yet implemented"); + const listResponse = await (await this.client).ds.listDatasets({ + pattern: likeDataSetName, + maxItems: 1, + attributes: true, + }); + + if (listResponse.items.length === 0) { + return this.buildZosFilesResponse( + { success: false }, + false, + `Source data set "${likeDataSetName}" not found`, + ); + } + + const sourceDs = listResponse.items[0]; + if (sourceDs.name?.toUpperCase() !== likeDataSetName.toUpperCase()) { + return this.buildZosFilesResponse( + { success: false }, + false, + `Source data set "${likeDataSetName}" not found`, + ); + } + + if (sourceDs.recfm === "U") { + Gui.errorMessage("RECFM=U data sets are not supported for copy operations"); + return this.buildZosFilesResponse( + { success: false }, + false, + "RECFM=U data sets are not supported for copy operations", + ); + } + + return this.buildZosFilesResponse({ success: true }, true); } public async copyDataSetMember( - { dsn: _fromDataSetName, member: _fromMemberName }: zosfiles.IDataSet, - { dsn: _toDataSetName, member: _toMemberName }: zosfiles.IDataSet, - _options?: { replace?: boolean }, + { dsn: fromDataSetName, member: fromMemberName }: zosfiles.IDataSet, + { dsn: toDataSetName, member: toMemberName }: zosfiles.IDataSet, + options?: { replace?: boolean; deleteTargetMembers?: boolean }, ): Promise { - throw new Error("Not yet implemented"); + const fromDataset = fromMemberName ? `${fromDataSetName}(${fromMemberName})` : fromDataSetName; + const toDataset = toMemberName ? `${toDataSetName}(${toMemberName})` : toDataSetName; + try { + const response = await (await this.client).ds.copyDataset({ + fromDataset, + toDataset, + replace: options?.replace ?? false, + deleteTargetMembers: options?.deleteTargetMembers ?? false, + }); + if (!response.success) { + const msg = this.copyDatasetFailureMessage(fromDataset, toDataset, response); + Gui.errorMessage(msg); + } + return this.buildZosFilesResponse( + response, + response.success, + response.success ? undefined : this.copyDatasetFailureMessage(fromDataset, toDataset, response), + ); + } catch (error) { + const errorDetails = error instanceof imperative.ImperativeError ? error.additionalDetails : String(error); + Gui.errorMessage(`Failed to copy "${fromDataset}" to "${toDataset}": ${errorDetails}`); + return this.buildZosFilesResponse({ success: false }, false, String(errorDetails)); + } + } + + public async copyDataSet( + fromDataSetName: string, + toDataSetName: string, + _enq?: string, + replace?: boolean, + ): Promise { + // _enq is a Zowe Explorer hook; the `copyDataset` RPC uses the same shape as `zowex data-set copy`. + try { + const response = await (await this.client).ds.copyDataset({ + fromDataset: fromDataSetName, + toDataset: toDataSetName, + replace: replace ?? false, + }); + if (!response.success) { + const msg = this.copyDatasetFailureMessage(fromDataSetName, toDataSetName, response); + Gui.errorMessage(msg); + } + return this.buildZosFilesResponse( + response, + response.success, + response.success ? undefined : this.copyDatasetFailureMessage(fromDataSetName, toDataSetName, response), + ); + } catch (error) { + const errorDetails = error instanceof imperative.ImperativeError ? error.additionalDetails : String(error); + Gui.errorMessage(`Failed to copy "${fromDataSetName}" to "${toDataSetName}": ${errorDetails}`); + return this.buildZosFilesResponse({ success: false }, false, String(errorDetails)); + } } public async renameDataSet( @@ -386,6 +476,14 @@ export class SshMvsApi extends SshCommonApi implements MainframeInteraction.IMvs }); } + /** User-visible copy failure line; includes stderr/message when the server adds them to the result object. */ + private copyDatasetFailureMessage(fromDs: string, toDs: string, response: ds.CopyDatasetResponse): string { + const base = `Failed to copy "${fromDs}" to "${toDs}"`; + const r = response as ds.CopyDatasetResponse & { stderr?: string; message?: string }; + const detail = r.stderr?.trim() || r.message?.trim(); + return detail ? `${base}: ${detail}` : base; + } + // biome-ignore lint/suspicious/noExplicitAny: apiResponse has no strong type private buildZosFilesResponse(apiResponse: any, success = true, errorText?: string): zosfiles.IZosFilesResponse { return { apiResponse, commandResponse: "", success, errorMessage: errorText };