@openvcs/sdk 0.4.1 → 0.4.2-edge.20260613.129

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/README.md CHANGED
@@ -174,6 +174,9 @@ compiled plugin author module at `bin/plugin.js`, and then generates the SDK-own
174
174
  `bin/<module.exec>` bootstrap that imports `./plugin.js`, applies `PluginDefinition`,
175
175
  invokes `OnPluginStart()`, and starts the runtime.
176
176
 
177
+ The build prints progress messages to stderr so you can see each phase. Pass `-V`
178
+ or `--verbose` to also echo the exact command and validation details.
179
+
177
180
  Theme-only plugins can also run `npm run build`; the command exits successfully
178
181
  without producing `bin/` output.
179
182
 
@@ -208,18 +211,18 @@ Dependency behavior:
208
211
 
209
212
  ## CLI usage
210
213
 
211
- Build a plugin manually:
214
+ Build plugin assets from a plugin project:
212
215
 
213
216
  ```bash
214
- npx openvcs build --plugin-dir /path/to/plugin
217
+ npm run build
215
218
  ```
216
219
 
217
- Show command help:
220
+ Show command help through npm script:
218
221
 
219
222
  ```bash
220
- npx openvcs --help
221
- npx openvcs build --help
222
- npx openvcs init --help
223
+ npm run openvcs -- --help
224
+ npm run openvcs -- build --help
225
+ npm run openvcs -- init --help
223
226
  ```
224
227
 
225
228
  ## Publishing Note
package/lib/build.d.ts CHANGED
@@ -16,17 +16,17 @@ export declare function buildUsage(commandName?: string): string;
16
16
  /** Parses `openvcs build` arguments. */
17
17
  export declare function parseBuildArgs(args: string[]): BuildArgs;
18
18
  /** Reads and validates the plugin manifest. */
19
- export declare function readManifest(pluginDir: string): ManifestInfo;
19
+ export declare function readManifest(pluginDir: string, verbose?: boolean): ManifestInfo;
20
20
  /** Verifies that a declared module entry resolves to a real file under `bin/`. */
21
- export declare function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined): void;
21
+ export declare function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined, verbose?: boolean): void;
22
22
  /** Returns the compiled plugin module path imported by the generated bootstrap. */
23
23
  export declare function authoredPluginModulePath(pluginDir: string): string;
24
24
  /** Ensures the plugin's authored module and generated bootstrap paths are compatible. */
25
- export declare function validateGeneratedBootstrapTargets(pluginDir: string, moduleExec: string | undefined): void;
25
+ export declare function validateGeneratedBootstrapTargets(pluginDir: string, moduleExec: string | undefined, verbose?: boolean): void;
26
26
  /** Renders the generated Node bootstrap that owns runtime startup. */
27
27
  export declare function renderGeneratedBootstrap(pluginModuleImportPath: string, isEsm: boolean): string;
28
28
  /** Writes the generated SDK-owned module entrypoint under `bin/<module.exec>`. */
29
- export declare function generateModuleBootstrap(pluginDir: string, moduleExec: string | undefined): void;
29
+ export declare function generateModuleBootstrap(pluginDir: string, moduleExec: string | undefined, verbose?: boolean): void;
30
30
  /** Returns whether the plugin repository has a `package.json`. */
31
31
  export declare function hasPackageJson(pluginDir: string): boolean;
32
32
  /** Runs a command in the given directory with optional verbose logging. */
package/lib/build.js CHANGED
@@ -54,6 +54,9 @@ const fs_utils_1 = require("./fs-utils");
54
54
  const npm_runner_1 = require("./npm-runner");
55
55
  var npm_runner_2 = require("./npm-runner");
56
56
  Object.defineProperty(exports, "npmExecutable", { enumerable: true, get: function () { return npm_runner_2.npmExecutable; } });
57
+ function writeBuildProgress(message) {
58
+ process.stderr.write(`openvcs build: ${message}\n`);
59
+ }
57
60
  const AUTHORED_PLUGIN_MODULE_BASENAME = "plugin.js";
58
61
  /** Formats help text for the build command. */
59
62
  function buildUsage(commandName = "openvcs") {
@@ -90,11 +93,14 @@ function parseBuildArgs(args) {
90
93
  };
91
94
  }
92
95
  /** Reads and validates the plugin manifest. */
93
- function readManifest(pluginDir) {
96
+ function readManifest(pluginDir, verbose = false) {
94
97
  const manifestPath = path.join(pluginDir, "package.json");
95
98
  let manifestRaw;
96
99
  let manifestFd;
97
100
  let manifest;
101
+ if (verbose) {
102
+ writeBuildProgress(`reading manifest at ${manifestPath}`);
103
+ }
98
104
  try {
99
105
  manifestFd = fs.openSync(manifestPath, "r");
100
106
  const manifestStat = fs.fstatSync(manifestFd);
@@ -138,6 +144,9 @@ function readManifest(pluginDir) {
138
144
  const moduleExec = typeof moduleValue?.exec === "string" ? moduleValue.exec.trim() : undefined;
139
145
  const entryValue = openvcs.entry;
140
146
  const entry = typeof entryValue === "string" ? entryValue.trim() : undefined;
147
+ if (verbose) {
148
+ writeBuildProgress(`manifest loaded for ${pluginId}${moduleExec ? ` (module.exec: ${moduleExec})` : ""}`);
149
+ }
141
150
  return {
142
151
  pluginId,
143
152
  moduleExec,
@@ -146,11 +155,14 @@ function readManifest(pluginDir) {
146
155
  };
147
156
  }
148
157
  /** Verifies that a declared module entry resolves to a real file under `bin/`. */
149
- function validateDeclaredModuleExec(pluginDir, moduleExec) {
158
+ function validateDeclaredModuleExec(pluginDir, moduleExec, verbose = false) {
150
159
  if (!moduleExec) {
151
160
  return;
152
161
  }
153
162
  const targetPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
163
+ if (verbose) {
164
+ writeBuildProgress(`checking declared bootstrap at ${targetPath}`);
165
+ }
154
166
  if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
155
167
  throw new Error(`module entrypoint not found at ${targetPath}`);
156
168
  }
@@ -177,10 +189,13 @@ function authoredPluginModulePath(pluginDir) {
177
189
  return path.resolve(pluginDir, "bin", AUTHORED_PLUGIN_MODULE_BASENAME);
178
190
  }
179
191
  /** Ensures the plugin's authored module and generated bootstrap paths are compatible. */
180
- function validateGeneratedBootstrapTargets(pluginDir, moduleExec) {
192
+ function validateGeneratedBootstrapTargets(pluginDir, moduleExec, verbose = false) {
181
193
  if (!moduleExec) {
182
194
  return;
183
195
  }
196
+ if (verbose) {
197
+ writeBuildProgress(`validating generated bootstrap target for ${moduleExec}`);
198
+ }
184
199
  resolveDeclaredModuleExecPath(pluginDir, moduleExec);
185
200
  const normalizedExec = moduleExec.trim().toLowerCase();
186
201
  if (normalizedExec === AUTHORED_PLUGIN_MODULE_BASENAME.toLowerCase()) {
@@ -211,16 +226,21 @@ function renderGeneratedBootstrap(pluginModuleImportPath, isEsm) {
211
226
  return `#!/usr/bin/env node\n// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\n(async () => {\n const { bootstrapPluginModule } = require('@openvcs/sdk/runtime');\n await bootstrapPluginModule({\n importPluginModule: async () => require('${pluginModuleImportPath}'),\n modulePath: '${pluginModuleImportPath}',\n });\n})();\n`;
212
227
  }
213
228
  /** Writes the generated SDK-owned module entrypoint under `bin/<module.exec>`. */
214
- function generateModuleBootstrap(pluginDir, moduleExec) {
229
+ function generateModuleBootstrap(pluginDir, moduleExec, verbose = false) {
215
230
  if (!moduleExec) {
216
- console.debug(`generateModuleBootstrap: no module.exec defined, skipping bootstrap generation for ${pluginDir}`);
231
+ if (verbose) {
232
+ writeBuildProgress(`no module.exec defined for ${pluginDir}; skipping bootstrap generation`);
233
+ }
217
234
  return;
218
235
  }
219
- validateGeneratedBootstrapTargets(pluginDir, moduleExec);
236
+ validateGeneratedBootstrapTargets(pluginDir, moduleExec, verbose);
220
237
  const execPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
221
238
  const pluginModulePath = authoredPluginModulePath(pluginDir);
222
239
  const pluginModuleImportPath = relativeBinImport(execPath, pluginModulePath);
223
240
  const isEsm = detectEsmMode(pluginDir, moduleExec);
241
+ if (verbose) {
242
+ writeBuildProgress(`writing bootstrap ${execPath} -> ${pluginModuleImportPath}${isEsm ? " (esm)" : " (cjs)"}`);
243
+ }
224
244
  fs.mkdirSync(path.dirname(execPath), { recursive: true });
225
245
  fs.writeFileSync(execPath, renderGeneratedBootstrap(pluginModuleImportPath, isEsm), "utf8");
226
246
  }
@@ -285,10 +305,13 @@ function readPackageScripts(pluginDir) {
285
305
  }
286
306
  /** Builds a plugin's runtime assets when it declares a code module. */
287
307
  function buildPluginAssets(parsedArgs) {
288
- const manifest = readManifest(parsedArgs.pluginDir);
308
+ writeBuildProgress(`reading plugin manifest in ${parsedArgs.pluginDir}`);
309
+ const manifest = readManifest(parsedArgs.pluginDir, parsedArgs.verbose);
289
310
  if (!manifest.moduleExec) {
311
+ writeBuildProgress(`theme-only plugin ${manifest.pluginId}; nothing to build`);
290
312
  return manifest;
291
313
  }
314
+ writeBuildProgress(`building plugin ${manifest.pluginId}`);
292
315
  if (!hasPackageJson(parsedArgs.pluginDir)) {
293
316
  throw new Error(`code plugins must include package.json: ${path.join(parsedArgs.pluginDir, "package.json")}`);
294
317
  }
@@ -297,8 +320,12 @@ function buildPluginAssets(parsedArgs) {
297
320
  if (typeof buildScript !== "string" || buildScript.trim() === "") {
298
321
  throw new Error(`code plugins must define scripts[\"build:plugin\"] in ${path.join(parsedArgs.pluginDir, "package.json")}`);
299
322
  }
323
+ writeBuildProgress(`running build:plugin`);
300
324
  runCommand((0, npm_runner_1.npmExecutable)(), [...(0, npm_runner_1.npmArgsPrefix)(), "run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
301
- generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec);
302
- validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
325
+ writeBuildProgress(`generating bootstrap ${manifest.moduleExec}`);
326
+ generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec, parsedArgs.verbose);
327
+ writeBuildProgress(`validating bootstrap ${manifest.moduleExec}`);
328
+ validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec, parsedArgs.verbose);
329
+ writeBuildProgress(`build complete for ${manifest.pluginId}`);
303
330
  return manifest;
304
331
  }
@@ -66,7 +66,7 @@ export declare abstract class VcsDelegateBase<TDeps, TContext = PluginRuntimeCon
66
66
  /** Handles `vcs.list_commits`. */
67
67
  listCommits(_params: VcsTypes.VcsListCommitsParams, _context: TContext): VcsHandlerResult<VcsTypes.CommitEntry[]>;
68
68
  /** Handles `vcs.diff_file`. */
69
- diffFile(_params: VcsTypes.VcsDiffFileParams, _context: TContext): VcsHandlerResult<string[]>;
69
+ diffFile(_params: VcsTypes.VcsDiffFileParams, _context: TContext): VcsHandlerResult<VcsTypes.VcsDiffFileResponse>;
70
70
  /** Handles `vcs.diff_commit`. */
71
71
  diffCommit(_params: VcsTypes.VcsDiffCommitParams, _context: TContext): VcsHandlerResult<string[]>;
72
72
  /** Handles `vcs.get_conflict_details`. */
@@ -77,6 +77,8 @@ export declare abstract class VcsDelegateBase<TDeps, TContext = PluginRuntimeCon
77
77
  writeMergeResult(_params: VcsTypes.VcsWriteMergeResultParams, _context: TContext): VcsHandlerResult<null>;
78
78
  /** Handles `vcs.stage_patch`. */
79
79
  stagePatch(_params: VcsTypes.VcsStagePatchParams, _context: TContext): VcsHandlerResult<null>;
80
+ /** Handles `vcs.stage_selections` (structured hunk/line selections). */
81
+ stageSelections(_params: VcsTypes.VcsStageSelectionsParams, _context: TContext): VcsHandlerResult<null>;
80
82
  /** Handles `vcs.stage_paths`. */
81
83
  stagePaths(_params: VcsTypes.VcsStagePathsParams, _context: TContext): VcsHandlerResult<null>;
82
84
  /** Handles `vcs.discard_paths`. */
@@ -157,6 +157,10 @@ class VcsDelegateBase {
157
157
  stagePatch(_params, _context) {
158
158
  return this.unimplemented('stagePatch');
159
159
  }
160
+ /** Handles `vcs.stage_selections` (structured hunk/line selections). */
161
+ stageSelections(_params, _context) {
162
+ return this.unimplemented('stageSelections');
163
+ }
160
164
  /** Handles `vcs.stage_paths`. */
161
165
  stagePaths(_params, _context) {
162
166
  return this.unimplemented('stagePaths');
@@ -30,6 +30,7 @@ export type VcsDelegateBindings<TContext> = {
30
30
  checkoutConflictSide: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.checkout_conflict_side']>;
31
31
  writeMergeResult: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.write_merge_result']>;
32
32
  stagePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_patch']>;
33
+ stageSelections: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_selections']>;
33
34
  stagePaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_paths']>;
34
35
  discardPaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.discard_paths']>;
35
36
  applyReversePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.apply_reverse_patch']>;
@@ -86,6 +87,7 @@ export declare const VCS_DELEGATE_METHOD_MAPPINGS: {
86
87
  readonly checkoutConflictSide: "vcs.checkout_conflict_side";
87
88
  readonly writeMergeResult: "vcs.write_merge_result";
88
89
  readonly stagePatch: "vcs.stage_patch";
90
+ readonly stageSelections: "vcs.stage_selections";
89
91
  readonly stagePaths: "vcs.stage_paths";
90
92
  readonly discardPaths: "vcs.discard_paths";
91
93
  readonly applyReversePatch: "vcs.apply_reverse_patch";
@@ -33,6 +33,7 @@ exports.VCS_DELEGATE_METHOD_MAPPINGS = {
33
33
  checkoutConflictSide: 'vcs.checkout_conflict_side',
34
34
  writeMergeResult: 'vcs.write_merge_result',
35
35
  stagePatch: 'vcs.stage_patch',
36
+ stageSelections: 'vcs.stage_selections',
36
37
  stagePaths: 'vcs.stage_paths',
37
38
  discardPaths: 'vcs.discard_paths',
38
39
  applyReversePatch: 'vcs.apply_reverse_patch',
@@ -13,6 +13,8 @@ export interface VcsCapabilities {
13
13
  push_pull: boolean;
14
14
  /** Indicates whether fast-forward helpers are supported. */
15
15
  fast_forward: boolean;
16
+ /** Merge strategies the backend supports (values like "merge", "squash", "rebase"). */
17
+ merge_strategies?: string[];
16
18
  }
17
19
  /** Describes params that carry a repository session id. */
18
20
  export interface VcsSessionParams extends RequestParams {
@@ -92,19 +94,12 @@ export interface VcsRemoveRemoteParams extends VcsSessionParams {
92
94
  /** Stores the remote name to remove. */
93
95
  name: string;
94
96
  }
95
- /** Describes optional fetch flags. */
96
- export interface VcsFetchOptions {
97
- /** Indicates whether stale remote references should be pruned. */
98
- prune?: boolean;
99
- }
100
97
  /** Describes params for fetch methods. */
101
98
  export interface VcsFetchParams extends VcsSessionParams {
102
99
  /** Stores the remote to fetch when one is supplied. */
103
100
  remote?: string;
104
101
  /** Stores the refspec to fetch when one is supplied. */
105
102
  refspec?: string;
106
- /** Stores optional fetch flags. */
107
- opts?: VcsFetchOptions;
108
103
  }
109
104
  /** Describes params for push. */
110
105
  export interface VcsPushParams extends VcsSessionParams {
@@ -156,6 +151,8 @@ export interface StatusFileEntry {
156
151
  resolved_conflict: boolean;
157
152
  /** Stores placeholder hunk information until richer diff support exists. */
158
153
  hunks: never[];
154
+ /** Indicates whether the file content should be treated as binary. */
155
+ binary?: boolean | null;
159
156
  }
160
157
  /** Describes the structured status payload returned to the host. */
161
158
  export interface StatusPayload {
@@ -224,6 +221,15 @@ export interface VcsDiffCommitParams extends VcsSessionParams {
224
221
  /** Stores the revision to diff. */
225
222
  rev: string;
226
223
  }
224
+ /** Describes one structured diff payload returned for a file. */
225
+ export interface VcsDiffResult {
226
+ /** Stores line-oriented diff output. */
227
+ lines: string[];
228
+ /** Indicates whether the diff target should be treated as binary. */
229
+ binary?: boolean | null;
230
+ }
231
+ /** Describes the accepted `vcs.diff_file` response shapes. */
232
+ export type VcsDiffFileResponse = VcsDiffResult | string[];
227
233
  /** Describes params for `vcs.get_conflict_details`. */
228
234
  export interface VcsGetConflictDetailsParams extends VcsSessionParams {
229
235
  /** Stores the conflicted path. */
@@ -241,8 +247,6 @@ export interface VcsConflictDetails {
241
247
  theirs: string | null;
242
248
  /** Indicates whether the conflict is binary. */
243
249
  binary: boolean;
244
- /** Indicates whether the conflict references Git LFS content. */
245
- lfs_pointer: boolean;
246
250
  }
247
251
  /** Describes params for checking out one side of a conflict. */
248
252
  export interface VcsCheckoutConflictSideParams extends VcsSessionParams {
@@ -263,6 +267,20 @@ export interface VcsStagePatchParams extends VcsSessionParams {
263
267
  /** Stores the textual patch content. */
264
268
  patch: string;
265
269
  }
270
+ /** Describes a single file's hunk/line selection for partial staging. */
271
+ export interface HunkSelection {
272
+ /** Repository-relative file path. */
273
+ path: string;
274
+ /** Indices of whole hunks to include. */
275
+ whole_hunks: number[];
276
+ /** Per-hunk line selections: maps hunk index → 1-based line offsets. */
277
+ partial_hunks: Record<number, number[]>;
278
+ }
279
+ /** Describes params for staging structured selections (VCS-agnostic). */
280
+ export interface VcsStageSelectionsParams extends VcsSessionParams {
281
+ /** Structured hunk/line selections for multiple files. */
282
+ selections: HunkSelection[];
283
+ }
266
284
  /** Describes params for staging repository-relative paths into the index. */
267
285
  export interface VcsStagePathsParams extends VcsSessionParams {
268
286
  /** Stores the paths to stage. */
@@ -292,12 +310,16 @@ export interface VcsRenameBranchParams extends VcsSessionParams {
292
310
  /** Stores the next branch name. */
293
311
  new: string;
294
312
  }
313
+ /** Describes the supported merge strategies. */
314
+ export type VcsMergeStrategy = 'merge' | 'squash' | 'rebase';
295
315
  /** Describes params for merging another branch into the current branch. */
296
316
  export interface VcsMergeIntoCurrentParams extends VcsSessionParams {
297
317
  /** Stores the branch name to merge. */
298
318
  name: string;
299
319
  /** Stores an optional merge commit message. */
300
320
  message?: string;
321
+ /** Stores the merge strategy. Defaults to 'merge' when absent. */
322
+ strategy?: VcsMergeStrategy;
301
323
  }
302
324
  /** Describes params for setting a branch upstream. */
303
325
  export interface VcsSetBranchUpstreamParams extends VcsSessionParams {
@@ -420,7 +442,7 @@ export interface VcsDelegates<TContext = unknown> {
420
442
  /** Handles `vcs.list_commits`. */
421
443
  'vcs.list_commits'?: RpcMethodHandler<VcsListCommitsParams, CommitEntry[], TContext>;
422
444
  /** Handles `vcs.diff_file`. */
423
- 'vcs.diff_file'?: RpcMethodHandler<VcsDiffFileParams, string[], TContext>;
445
+ 'vcs.diff_file'?: RpcMethodHandler<VcsDiffFileParams, VcsDiffFileResponse, TContext>;
424
446
  /** Handles `vcs.diff_commit`. */
425
447
  'vcs.diff_commit'?: RpcMethodHandler<VcsDiffCommitParams, string[], TContext>;
426
448
  /** Handles `vcs.get_conflict_details`. */
@@ -431,6 +453,8 @@ export interface VcsDelegates<TContext = unknown> {
431
453
  'vcs.write_merge_result'?: RpcMethodHandler<VcsWriteMergeResultParams, null, TContext>;
432
454
  /** Handles `vcs.stage_patch`. */
433
455
  'vcs.stage_patch'?: RpcMethodHandler<VcsStagePatchParams, null, TContext>;
456
+ /** Handles `vcs.stage_selections` (structured hunk/line selections). */
457
+ 'vcs.stage_selections'?: RpcMethodHandler<VcsStageSelectionsParams, null, TContext>;
434
458
  /** Handles `vcs.stage_paths`. */
435
459
  'vcs.stage_paths'?: RpcMethodHandler<VcsStagePathsParams, null, TContext>;
436
460
  /** Handles `vcs.discard_paths`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/sdk",
3
- "version": "0.4.1",
3
+ "version": "0.4.2-edge.20260613.129",
4
4
  "description": "OpenVCS SDK CLI for plugin scaffolding and runtime asset builds",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://openvcs.app/",
package/src/lib/build.ts CHANGED
@@ -26,6 +26,10 @@ export interface ManifestInfo {
26
26
  manifestPath: string;
27
27
  }
28
28
 
29
+ function writeBuildProgress(message: string): void {
30
+ process.stderr.write(`openvcs build: ${message}\n`);
31
+ }
32
+
29
33
  interface CommandResult {
30
34
  status: number | null;
31
35
  error?: Error;
@@ -76,11 +80,14 @@ export function parseBuildArgs(args: string[]): BuildArgs {
76
80
  }
77
81
 
78
82
  /** Reads and validates the plugin manifest. */
79
- export function readManifest(pluginDir: string): ManifestInfo {
83
+ export function readManifest(pluginDir: string, verbose = false): ManifestInfo {
80
84
  const manifestPath = path.join(pluginDir, "package.json");
81
85
  let manifestRaw: string;
82
86
  let manifestFd: number | undefined;
83
87
  let manifest: unknown;
88
+ if (verbose) {
89
+ writeBuildProgress(`reading manifest at ${manifestPath}`);
90
+ }
84
91
  try {
85
92
  manifestFd = fs.openSync(manifestPath, "r");
86
93
  const manifestStat = fs.fstatSync(manifestFd);
@@ -130,6 +137,10 @@ export function readManifest(pluginDir: string): ManifestInfo {
130
137
  const entryValue = openvcs.entry;
131
138
  const entry = typeof entryValue === "string" ? entryValue.trim() : undefined;
132
139
 
140
+ if (verbose) {
141
+ writeBuildProgress(`manifest loaded for ${pluginId}${moduleExec ? ` (module.exec: ${moduleExec})` : ""}`);
142
+ }
143
+
133
144
  return {
134
145
  pluginId,
135
146
  moduleExec,
@@ -139,12 +150,19 @@ export function readManifest(pluginDir: string): ManifestInfo {
139
150
  }
140
151
 
141
152
  /** Verifies that a declared module entry resolves to a real file under `bin/`. */
142
- export function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined): void {
153
+ export function validateDeclaredModuleExec(
154
+ pluginDir: string,
155
+ moduleExec: string | undefined,
156
+ verbose = false,
157
+ ): void {
143
158
  if (!moduleExec) {
144
159
  return;
145
160
  }
146
161
 
147
162
  const targetPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
163
+ if (verbose) {
164
+ writeBuildProgress(`checking declared bootstrap at ${targetPath}`);
165
+ }
148
166
  if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
149
167
  throw new Error(`module entrypoint not found at ${targetPath}`);
150
168
  }
@@ -179,11 +197,15 @@ export function authoredPluginModulePath(pluginDir: string): string {
179
197
  export function validateGeneratedBootstrapTargets(
180
198
  pluginDir: string,
181
199
  moduleExec: string | undefined,
200
+ verbose = false,
182
201
  ): void {
183
202
  if (!moduleExec) {
184
203
  return;
185
204
  }
186
205
 
206
+ if (verbose) {
207
+ writeBuildProgress(`validating generated bootstrap target for ${moduleExec}`);
208
+ }
187
209
  resolveDeclaredModuleExecPath(pluginDir, moduleExec);
188
210
  const normalizedExec = moduleExec.trim().toLowerCase();
189
211
  if (normalizedExec === AUTHORED_PLUGIN_MODULE_BASENAME.toLowerCase()) {
@@ -230,18 +252,28 @@ export function renderGeneratedBootstrap(
230
252
  }
231
253
 
232
254
  /** Writes the generated SDK-owned module entrypoint under `bin/<module.exec>`. */
233
- export function generateModuleBootstrap(pluginDir: string, moduleExec: string | undefined): void {
255
+ export function generateModuleBootstrap(
256
+ pluginDir: string,
257
+ moduleExec: string | undefined,
258
+ verbose = false,
259
+ ): void {
234
260
  if (!moduleExec) {
235
- console.debug(`generateModuleBootstrap: no module.exec defined, skipping bootstrap generation for ${pluginDir}`);
261
+ if (verbose) {
262
+ writeBuildProgress(`no module.exec defined for ${pluginDir}; skipping bootstrap generation`);
263
+ }
236
264
  return;
237
265
  }
238
266
 
239
- validateGeneratedBootstrapTargets(pluginDir, moduleExec);
267
+ validateGeneratedBootstrapTargets(pluginDir, moduleExec, verbose);
240
268
  const execPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
241
269
  const pluginModulePath = authoredPluginModulePath(pluginDir);
242
270
  const pluginModuleImportPath = relativeBinImport(execPath, pluginModulePath);
243
271
  const isEsm = detectEsmMode(pluginDir, moduleExec);
244
272
 
273
+ if (verbose) {
274
+ writeBuildProgress(`writing bootstrap ${execPath} -> ${pluginModuleImportPath}${isEsm ? " (esm)" : " (cjs)"}`);
275
+ }
276
+
245
277
  fs.mkdirSync(path.dirname(execPath), { recursive: true });
246
278
  fs.writeFileSync(execPath, renderGeneratedBootstrap(pluginModuleImportPath, isEsm), "utf8");
247
279
  }
@@ -313,11 +345,14 @@ function readPackageScripts(pluginDir: string): PackageScripts {
313
345
 
314
346
  /** Builds a plugin's runtime assets when it declares a code module. */
315
347
  export function buildPluginAssets(parsedArgs: BuildArgs): ManifestInfo {
316
- const manifest = readManifest(parsedArgs.pluginDir);
348
+ writeBuildProgress(`reading plugin manifest in ${parsedArgs.pluginDir}`);
349
+ const manifest = readManifest(parsedArgs.pluginDir, parsedArgs.verbose);
317
350
  if (!manifest.moduleExec) {
351
+ writeBuildProgress(`theme-only plugin ${manifest.pluginId}; nothing to build`);
318
352
  return manifest;
319
353
  }
320
354
 
355
+ writeBuildProgress(`building plugin ${manifest.pluginId}`);
321
356
  if (!hasPackageJson(parsedArgs.pluginDir)) {
322
357
  throw new Error(`code plugins must include package.json: ${path.join(parsedArgs.pluginDir, "package.json")}`);
323
358
  }
@@ -330,8 +365,12 @@ export function buildPluginAssets(parsedArgs: BuildArgs): ManifestInfo {
330
365
  );
331
366
  }
332
367
 
368
+ writeBuildProgress(`running build:plugin`);
333
369
  runCommand(npmExecutable(), [...npmArgsPrefix(), "run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
334
- generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec);
335
- validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
370
+ writeBuildProgress(`generating bootstrap ${manifest.moduleExec}`);
371
+ generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec, parsedArgs.verbose);
372
+ writeBuildProgress(`validating bootstrap ${manifest.moduleExec}`);
373
+ validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec, parsedArgs.verbose);
374
+ writeBuildProgress(`build complete for ${manifest.pluginId}`);
336
375
  return manifest;
337
376
  }
@@ -259,7 +259,7 @@ export abstract class VcsDelegateBase<
259
259
  diffFile(
260
260
  _params: VcsTypes.VcsDiffFileParams,
261
261
  _context: TContext,
262
- ): VcsHandlerResult<string[]> {
262
+ ): VcsHandlerResult<VcsTypes.VcsDiffFileResponse> {
263
263
  return this.unimplemented('diffFile');
264
264
  }
265
265
 
@@ -303,6 +303,14 @@ export abstract class VcsDelegateBase<
303
303
  return this.unimplemented('stagePatch');
304
304
  }
305
305
 
306
+ /** Handles `vcs.stage_selections` (structured hunk/line selections). */
307
+ stageSelections(
308
+ _params: VcsTypes.VcsStageSelectionsParams,
309
+ _context: TContext,
310
+ ): VcsHandlerResult<null> {
311
+ return this.unimplemented('stageSelections');
312
+ }
313
+
306
314
  /** Handles `vcs.stage_paths`. */
307
315
  stagePaths(
308
316
  _params: VcsTypes.VcsStagePathsParams,
@@ -53,6 +53,7 @@ export type VcsDelegateBindings<TContext> = {
53
53
  VcsTypes.VcsDelegates<TContext>['vcs.write_merge_result']
54
54
  >;
55
55
  stagePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_patch']>;
56
+ stageSelections: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_selections']>;
56
57
  stagePaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_paths']>;
57
58
  discardPaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.discard_paths']>;
58
59
  applyReversePatch: NonNullable<
@@ -123,6 +124,7 @@ export const VCS_DELEGATE_METHOD_MAPPINGS = {
123
124
  checkoutConflictSide: 'vcs.checkout_conflict_side',
124
125
  writeMergeResult: 'vcs.write_merge_result',
125
126
  stagePatch: 'vcs.stage_patch',
127
+ stageSelections: 'vcs.stage_selections',
126
128
  stagePaths: 'vcs.stage_paths',
127
129
  discardPaths: 'vcs.discard_paths',
128
130
  applyReversePatch: 'vcs.apply_reverse_patch',
@@ -17,6 +17,8 @@ export interface VcsCapabilities {
17
17
  push_pull: boolean;
18
18
  /** Indicates whether fast-forward helpers are supported. */
19
19
  fast_forward: boolean;
20
+ /** Merge strategies the backend supports (values like "merge", "squash", "rebase"). */
21
+ merge_strategies?: string[];
20
22
  }
21
23
 
22
24
  /** Describes params that carry a repository session id. */
@@ -110,20 +112,13 @@ export interface VcsRemoveRemoteParams extends VcsSessionParams {
110
112
  name: string;
111
113
  }
112
114
 
113
- /** Describes optional fetch flags. */
114
- export interface VcsFetchOptions {
115
- /** Indicates whether stale remote references should be pruned. */
116
- prune?: boolean;
117
- }
118
-
119
115
  /** Describes params for fetch methods. */
116
+
120
117
  export interface VcsFetchParams extends VcsSessionParams {
121
118
  /** Stores the remote to fetch when one is supplied. */
122
119
  remote?: string;
123
120
  /** Stores the refspec to fetch when one is supplied. */
124
121
  refspec?: string;
125
- /** Stores optional fetch flags. */
126
- opts?: VcsFetchOptions;
127
122
  }
128
123
 
129
124
  /** Describes params for push. */
@@ -180,6 +175,8 @@ export interface StatusFileEntry {
180
175
  resolved_conflict: boolean;
181
176
  /** Stores placeholder hunk information until richer diff support exists. */
182
177
  hunks: never[];
178
+ /** Indicates whether the file content should be treated as binary. */
179
+ binary?: boolean | null;
183
180
  }
184
181
 
185
182
  /** Describes the structured status payload returned to the host. */
@@ -256,6 +253,17 @@ export interface VcsDiffCommitParams extends VcsSessionParams {
256
253
  rev: string;
257
254
  }
258
255
 
256
+ /** Describes one structured diff payload returned for a file. */
257
+ export interface VcsDiffResult {
258
+ /** Stores line-oriented diff output. */
259
+ lines: string[];
260
+ /** Indicates whether the diff target should be treated as binary. */
261
+ binary?: boolean | null;
262
+ }
263
+
264
+ /** Describes the accepted `vcs.diff_file` response shapes. */
265
+ export type VcsDiffFileResponse = VcsDiffResult | string[];
266
+
259
267
  /** Describes params for `vcs.get_conflict_details`. */
260
268
  export interface VcsGetConflictDetailsParams extends VcsSessionParams {
261
269
  /** Stores the conflicted path. */
@@ -274,8 +282,6 @@ export interface VcsConflictDetails {
274
282
  theirs: string | null;
275
283
  /** Indicates whether the conflict is binary. */
276
284
  binary: boolean;
277
- /** Indicates whether the conflict references Git LFS content. */
278
- lfs_pointer: boolean;
279
285
  }
280
286
 
281
287
  /** Describes params for checking out one side of a conflict. */
@@ -300,6 +306,22 @@ export interface VcsStagePatchParams extends VcsSessionParams {
300
306
  patch: string;
301
307
  }
302
308
 
309
+ /** Describes a single file's hunk/line selection for partial staging. */
310
+ export interface HunkSelection {
311
+ /** Repository-relative file path. */
312
+ path: string;
313
+ /** Indices of whole hunks to include. */
314
+ whole_hunks: number[];
315
+ /** Per-hunk line selections: maps hunk index → 1-based line offsets. */
316
+ partial_hunks: Record<number, number[]>;
317
+ }
318
+
319
+ /** Describes params for staging structured selections (VCS-agnostic). */
320
+ export interface VcsStageSelectionsParams extends VcsSessionParams {
321
+ /** Structured hunk/line selections for multiple files. */
322
+ selections: HunkSelection[];
323
+ }
324
+
303
325
  /** Describes params for staging repository-relative paths into the index. */
304
326
  export interface VcsStagePathsParams extends VcsSessionParams {
305
327
  /** Stores the paths to stage. */
@@ -334,12 +356,17 @@ export interface VcsRenameBranchParams extends VcsSessionParams {
334
356
  new: string;
335
357
  }
336
358
 
359
+ /** Describes the supported merge strategies. */
360
+ export type VcsMergeStrategy = 'merge' | 'squash' | 'rebase';
361
+
337
362
  /** Describes params for merging another branch into the current branch. */
338
363
  export interface VcsMergeIntoCurrentParams extends VcsSessionParams {
339
364
  /** Stores the branch name to merge. */
340
365
  name: string;
341
366
  /** Stores an optional merge commit message. */
342
367
  message?: string;
368
+ /** Stores the merge strategy. Defaults to 'merge' when absent. */
369
+ strategy?: VcsMergeStrategy;
343
370
  }
344
371
 
345
372
  /** Describes params for setting a branch upstream. */
@@ -503,7 +530,7 @@ export interface VcsDelegates<TContext = unknown> {
503
530
  TContext
504
531
  >;
505
532
  /** Handles `vcs.diff_file`. */
506
- 'vcs.diff_file'?: RpcMethodHandler<VcsDiffFileParams, string[], TContext>;
533
+ 'vcs.diff_file'?: RpcMethodHandler<VcsDiffFileParams, VcsDiffFileResponse, TContext>;
507
534
  /** Handles `vcs.diff_commit`. */
508
535
  'vcs.diff_commit'?: RpcMethodHandler<VcsDiffCommitParams, string[], TContext>;
509
536
  /** Handles `vcs.get_conflict_details`. */
@@ -526,6 +553,8 @@ export interface VcsDelegates<TContext = unknown> {
526
553
  >;
527
554
  /** Handles `vcs.stage_patch`. */
528
555
  'vcs.stage_patch'?: RpcMethodHandler<VcsStagePatchParams, null, TContext>;
556
+ /** Handles `vcs.stage_selections` (structured hunk/line selections). */
557
+ 'vcs.stage_selections'?: RpcMethodHandler<VcsStageSelectionsParams, null, TContext>;
529
558
  /** Handles `vcs.stage_paths`. */
530
559
  'vcs.stage_paths'?: RpcMethodHandler<VcsStagePathsParams, null, TContext>;
531
560
  /** Handles `vcs.discard_paths`. */
@@ -16,6 +16,20 @@ const {
16
16
  } = require("../lib/build");
17
17
  const { cleanupTempDir, makeTempDir, writeJson, writeText } = require("./helpers");
18
18
 
19
+ function captureStderr(fn) {
20
+ const chunks = [];
21
+ const originalWrite = process.stderr.write;
22
+ process.stderr.write = (chunk) => {
23
+ chunks.push(String(chunk));
24
+ return true;
25
+ };
26
+ try {
27
+ return { result: fn(), stderr: chunks.join("") };
28
+ } finally {
29
+ process.stderr.write = originalWrite;
30
+ }
31
+ }
32
+
19
33
  test("renderGeneratedBootstrap creates ESM code", () => {
20
34
  const output = require("../lib/build").renderGeneratedBootstrap("./plugin.js", true);
21
35
  assert.match(output, /^#!/);
@@ -64,9 +78,11 @@ test("buildPluginAssets no-ops for theme-only plugins", () => {
64
78
  name: "theme-only",
65
79
  openvcs: { id: "theme-only" },
66
80
  });
67
- const manifest = buildPluginAssets({ pluginDir, verbose: false });
81
+ const { result: manifest, stderr } = captureStderr(() => buildPluginAssets({ pluginDir, verbose: false }));
68
82
 
69
83
  assert.equal(manifest.pluginId, "theme-only");
84
+ assert.match(stderr, /openvcs build: reading plugin manifest/);
85
+ assert.match(stderr, /openvcs build: theme-only plugin theme-only; nothing to build/);
70
86
  cleanupTempDir(root);
71
87
  });
72
88
 
@@ -110,7 +126,7 @@ test("buildPluginAssets runs build:plugin and validates output", () => {
110
126
  "const fs = require('node:fs');\nconst path = require('node:path');\nconst out = path.join(process.cwd(), 'bin', 'plugin.js');\nfs.mkdirSync(path.dirname(out), { recursive: true });\nfs.writeFileSync(out, 'export {};\\n', 'utf8');\n"
111
127
  );
112
128
 
113
- const manifest = buildPluginAssets({ pluginDir, verbose: false });
129
+ const { result: manifest, stderr } = captureStderr(() => buildPluginAssets({ pluginDir, verbose: false }));
114
130
 
115
131
  assert.equal(manifest.pluginId, "builder");
116
132
  assert.equal(fs.existsSync(path.join(pluginDir, "bin", "plugin.js")), true);
@@ -119,6 +135,42 @@ test("buildPluginAssets runs build:plugin and validates output", () => {
119
135
  fs.readFileSync(path.join(pluginDir, "bin", "openvcs-plugin.js"), "utf8"),
120
136
  /bootstrapPluginModule/
121
137
  );
138
+ assert.match(stderr, /openvcs build: reading plugin manifest/);
139
+ assert.match(stderr, /openvcs build: running build:plugin/);
140
+ assert.match(stderr, /openvcs build: generating bootstrap openvcs-plugin.js/);
141
+ assert.match(stderr, /openvcs build: validating bootstrap openvcs-plugin.js/);
142
+ assert.match(stderr, /openvcs build: build complete for builder/);
143
+ cleanupTempDir(root);
144
+ });
145
+
146
+ test("buildPluginAssets verbose mode adds detailed progress output", () => {
147
+ const root = makeTempDir("openvcs-sdk-test");
148
+ const pluginDir = path.join(root, "plugin");
149
+
150
+ writeJson(path.join(pluginDir, "package.json"), {
151
+ name: "verbose-builder",
152
+ private: true,
153
+ type: "module",
154
+ openvcs: {
155
+ id: "verbose-builder",
156
+ module: { exec: "openvcs-plugin.mjs" },
157
+ },
158
+ scripts: {
159
+ "build:plugin": "node ./scripts/build-plugin.cjs",
160
+ },
161
+ });
162
+ writeText(
163
+ path.join(pluginDir, "scripts", "build-plugin.cjs"),
164
+ "const fs = require('node:fs');\nconst path = require('node:path');\nconst out = path.join(process.cwd(), 'bin', 'plugin.js');\nfs.mkdirSync(path.dirname(out), { recursive: true });\nfs.writeFileSync(out, 'export {};\\n', 'utf8');\n"
165
+ );
166
+
167
+ const { result: manifest, stderr } = captureStderr(() => buildPluginAssets({ pluginDir, verbose: true }));
168
+
169
+ assert.equal(manifest.pluginId, "verbose-builder");
170
+ assert.match(stderr, /openvcs build: reading manifest at .*package\.json/);
171
+ assert.match(stderr, /openvcs build: manifest loaded for verbose-builder \(module\.exec: openvcs-plugin\.mjs\)/);
172
+ assert.match(stderr, /Running command in .*: .* run build:plugin/);
173
+ assert.match(stderr, /openvcs build: writing bootstrap .*openvcs-plugin\.mjs -> .*plugin\.js \(esm\)/);
122
174
  cleanupTempDir(root);
123
175
  });
124
176
 
@@ -53,6 +53,34 @@ class DerivedBranchDelegates extends SharedBranchDelegates {
53
53
  }
54
54
  }
55
55
 
56
+ class BinaryAwareDelegates extends VcsDelegateBase {
57
+ getStatusPayload() {
58
+ return {
59
+ files: [
60
+ {
61
+ path: 'img.png',
62
+ old_path: null,
63
+ status: 'M',
64
+ staged: false,
65
+ resolved_conflict: false,
66
+ hunks: [],
67
+ binary: true,
68
+ },
69
+ ],
70
+ ahead: 0,
71
+ behind: 0,
72
+ branch_on_remote: false,
73
+ };
74
+ }
75
+
76
+ diffFile() {
77
+ return {
78
+ lines: ['Binary files a/img.png and b/img.png differ'],
79
+ binary: true,
80
+ };
81
+ }
82
+ }
83
+
56
84
  test('VcsDelegateBase maps overridden camelCase methods to rpc delegates', async () => {
57
85
  const delegate = new ExampleVcsDelegates({ prefix: 'commit', calls: [] });
58
86
  const delegates = delegate.toDelegates();
@@ -110,6 +138,44 @@ test('VcsDelegateBase keeps inherited overrides when building delegates', async
110
138
  );
111
139
  });
112
140
 
141
+ test('VcsDelegateBase preserves binary metadata in status and diff payloads', async () => {
142
+ const delegates = new BinaryAwareDelegates({}).toDelegates();
143
+
144
+ assert.deepEqual(
145
+ await delegates['vcs.get_status_payload'](
146
+ { session_id: 'session-4' },
147
+ { host: {}, method: 'vcs.get_status_payload', requestId: 11 },
148
+ ),
149
+ {
150
+ files: [
151
+ {
152
+ path: 'img.png',
153
+ old_path: null,
154
+ status: 'M',
155
+ staged: false,
156
+ resolved_conflict: false,
157
+ hunks: [],
158
+ binary: true,
159
+ },
160
+ ],
161
+ ahead: 0,
162
+ behind: 0,
163
+ branch_on_remote: false,
164
+ },
165
+ );
166
+
167
+ assert.deepEqual(
168
+ await delegates['vcs.diff_file'](
169
+ { session_id: 'session-4', path: 'img.png' },
170
+ { host: {}, method: 'vcs.diff_file', requestId: 12 },
171
+ ),
172
+ {
173
+ lines: ['Binary files a/img.png and b/img.png differ'],
174
+ binary: true,
175
+ },
176
+ );
177
+ });
178
+
113
179
  test('VcsDelegateBase returns a fresh delegate map on each call', async () => {
114
180
  const delegate = new ExampleVcsDelegates({ prefix: 'repeat', calls: [] });
115
181
  const firstDelegates = delegate.toDelegates();