opencode-ship 1.1.7 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core.js CHANGED
@@ -1,4 +1,4 @@
1
- // opencode-ship/core v1.1.7
1
+ // opencode-ship/core v1.1.8
2
2
 
3
3
  // src/adapter.js
4
4
  import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
@@ -851,7 +851,7 @@ function validateGhArgv(argv) {
851
851
 
852
852
  // src/drivers/gh-cli.js
853
853
  function defaultRunner(cwd, env) {
854
- return (args) => new Promise((resolve10, reject) => {
854
+ return (args) => new Promise((resolve11, reject) => {
855
855
  const proc = spawn2("gh", args, {
856
856
  cwd,
857
857
  env,
@@ -863,7 +863,7 @@ function defaultRunner(cwd, env) {
863
863
  proc.stdout.on("data", (d) => stdout += d.toString());
864
864
  proc.stderr.on("data", (d) => stderr += d.toString());
865
865
  proc.on("error", reject);
866
- proc.on("close", (status) => resolve10({ status: status ?? -1, stdout, stderr }));
866
+ proc.on("close", (status) => resolve11({ status: status ?? -1, stdout, stderr }));
867
867
  });
868
868
  }
869
869
  function viewFields() {
@@ -1532,6 +1532,9 @@ function createWorktreeTool(deps) {
1532
1532
  expectedRoot: resolve7(deps.repoRoot, worktreeRoot)
1533
1533
  };
1534
1534
  }
1535
+ if (m.schemaVersion >= 2 && !m.workflowId) {
1536
+ return { kind: "missing-workflow-link", taskId: m.taskId };
1537
+ }
1535
1538
  const remote = deps.remote ?? "origin";
1536
1539
  const hasRemote = remoteExists(remote, deps.repoRoot);
1537
1540
  if (hasRemote) {
@@ -2775,11 +2778,432 @@ function createCleanupTool(deps) {
2775
2778
  };
2776
2779
  }
2777
2780
 
2781
+ // src/tools/delivery-abandon.js
2782
+ import { join as join10 } from "node:path";
2783
+ import { spawnSync as spawnSync4 } from "node:child_process";
2784
+ import { existsSync as existsSync8 } from "node:fs";
2785
+
2786
+ // src/state/abandon-store.js
2787
+ import { readFile as readFile7 } from "node:fs/promises";
2788
+ import { join as join8 } from "node:path";
2789
+ import { createHash as createHash7 } from "node:crypto";
2790
+ var SAFE_ID_RE2 = /^[A-Za-z0-9._-]{1,128}$/;
2791
+ var HASH_RE2 = /^[0-9a-f]{64}$/;
2792
+ function abandonDir(commonDir, taskId) {
2793
+ return join8(commonDir, "opencode-ship", "delivery", "abandoned", taskId);
2794
+ }
2795
+ function intentPathFor(commonDir, taskId) {
2796
+ return join8(abandonDir(commonDir, taskId), "intent.json");
2797
+ }
2798
+ function completionPathFor(commonDir, taskId) {
2799
+ return join8(abandonDir(commonDir, taskId), "completion.json");
2800
+ }
2801
+ async function readJsonOrNull2(path) {
2802
+ try {
2803
+ return JSON.parse(await readFile7(path, "utf8"));
2804
+ } catch (err) {
2805
+ if (err?.code === "ENOENT") return null;
2806
+ throw err;
2807
+ }
2808
+ }
2809
+ function withoutIntentHash(record) {
2810
+ const copy = { ...record ?? {} };
2811
+ delete copy.intentHash;
2812
+ return copy;
2813
+ }
2814
+ function hashAbandonIntent(record) {
2815
+ return createHash7("sha256").update(canonicalJson(withoutIntentHash(record)), "utf8").digest("hex");
2816
+ }
2817
+ async function readAbandon(repoRoot, taskId) {
2818
+ const commonDir = await resolveGitCommonDir(repoRoot);
2819
+ if (!SAFE_ID_RE2.test(String(taskId ?? ""))) {
2820
+ return { intent: null, completion: null };
2821
+ }
2822
+ return {
2823
+ intent: await readJsonOrNull2(intentPathFor(commonDir, taskId)),
2824
+ completion: await readJsonOrNull2(completionPathFor(commonDir, taskId))
2825
+ };
2826
+ }
2827
+ async function publishOrReuse(path, record) {
2828
+ try {
2829
+ await publishImmutableJson(path, record);
2830
+ return { ok: true, record, idempotent: false };
2831
+ } catch (err) {
2832
+ const message = String(err?.message ?? err);
2833
+ if (!message.includes("already exists")) throw err;
2834
+ const existing = await readJsonOrNull2(path);
2835
+ if (existing && canonicalJson(existing) === canonicalJson(record)) {
2836
+ return { ok: true, record: existing, idempotent: true };
2837
+ }
2838
+ return { ok: false, kind: "abandon-conflict" };
2839
+ }
2840
+ }
2841
+ async function publishAbandonIntent(repoRoot, record) {
2842
+ const taskId = String(record?.taskId ?? "");
2843
+ if (!SAFE_ID_RE2.test(taskId)) {
2844
+ return { ok: false, kind: "invalid-task-id" };
2845
+ }
2846
+ const sealed = { ...withoutIntentHash(record), intentHash: hashAbandonIntent(record) };
2847
+ const commonDir = await resolveGitCommonDir(repoRoot);
2848
+ return publishOrReuse(intentPathFor(commonDir, taskId), sealed);
2849
+ }
2850
+ async function publishAbandonCompletion(repoRoot, record) {
2851
+ const taskId = String(record?.taskId ?? "");
2852
+ const intentHash = String(record?.intentHash ?? "");
2853
+ if (!SAFE_ID_RE2.test(taskId)) {
2854
+ return { ok: false, kind: "invalid-task-id" };
2855
+ }
2856
+ if (!HASH_RE2.test(intentHash)) {
2857
+ return { ok: false, kind: "invalid-intent-hash" };
2858
+ }
2859
+ const commonDir = await resolveGitCommonDir(repoRoot);
2860
+ return publishOrReuse(completionPathFor(commonDir, taskId), record);
2861
+ }
2862
+
2863
+ // src/skills/worktree.js
2864
+ import { execFile } from "node:child_process";
2865
+ import { promises as fs, existsSync as existsSync7 } from "node:fs";
2866
+ import { resolve as resolve9, dirname as dirname3, sep, isAbsolute, join as join9 } from "node:path";
2867
+ function listRegisteredWorktrees(mainRepo) {
2868
+ return new Promise((resolveP, rejectP) => {
2869
+ execFile(
2870
+ "git",
2871
+ ["-C", mainRepo, "worktree", "list", "--porcelain", "-z"],
2872
+ { shell: false, maxBuffer: 1024 * 1024 },
2873
+ (err, stdout) => {
2874
+ if (err) return rejectP(err);
2875
+ const records = parsePorcelain(stdout);
2876
+ const mainRecord = records.shift();
2877
+ const mainPath = mainRecord?.worktree ? resolve9(mainRecord.worktree) : null;
2878
+ const linked = [];
2879
+ for (const r of records) {
2880
+ if (!r.worktree) continue;
2881
+ const p = resolve9(r.worktree);
2882
+ if (mainPath && p === mainPath) continue;
2883
+ linked.push({ path: p, branch: r.HEAD ?? null });
2884
+ }
2885
+ resolveP(linked);
2886
+ }
2887
+ );
2888
+ });
2889
+ }
2890
+ function parsePorcelain(text) {
2891
+ const tokens = text.split("\0");
2892
+ const out = [];
2893
+ let current = {};
2894
+ for (const tok of tokens) {
2895
+ if (tok.length === 0) {
2896
+ if (Object.keys(current).length > 0) {
2897
+ out.push(current);
2898
+ current = {};
2899
+ }
2900
+ continue;
2901
+ }
2902
+ const idx = tok.indexOf(" ");
2903
+ const key = idx === -1 ? tok : tok.slice(0, idx);
2904
+ const value = idx === -1 ? "" : tok.slice(idx + 1);
2905
+ if (key === "branch") {
2906
+ current.HEAD = value.startsWith("refs/heads/") ? value : `refs/heads/${value}`;
2907
+ } else {
2908
+ current[key] = value;
2909
+ }
2910
+ }
2911
+ if (Object.keys(current).length > 0) out.push(current);
2912
+ return out;
2913
+ }
2914
+ async function validateLinkedWorktree(mainRepo, worktreePath, options = {}) {
2915
+ const main = resolve9(mainRepo);
2916
+ if (!existsSync7(main)) {
2917
+ return { ok: false, kind: "missing", message: `main repository ${main} does not exist` };
2918
+ }
2919
+ if (!worktreePath) {
2920
+ return { ok: false, kind: "unlinked", message: "worktreePath is required" };
2921
+ }
2922
+ const wt = resolve9(worktreePath);
2923
+ if (!existsSync7(wt)) {
2924
+ return { ok: false, kind: "missing", message: `worktree ${wt} does not exist` };
2925
+ }
2926
+ const isCurrent = wt === main;
2927
+ if (isCurrent) {
2928
+ const gitEntry = options.allowCurrentLinked ? await fs.lstat(join9(main, ".git")).catch(() => null) : null;
2929
+ if (!gitEntry?.isFile()) {
2930
+ return { ok: false, kind: "main", message: "installs into the main worktree are forbidden" };
2931
+ }
2932
+ }
2933
+ let cursor = wt;
2934
+ while (cursor !== dirname3(cursor)) {
2935
+ const stat2 = await fs.lstat(cursor).catch(() => null);
2936
+ if (stat2?.isSymbolicLink()) {
2937
+ return {
2938
+ ok: false,
2939
+ kind: "ancestor-symlink",
2940
+ message: `worktree path contains a symlink at ${cursor}`
2941
+ };
2942
+ }
2943
+ cursor = dirname3(cursor);
2944
+ }
2945
+ const real = await fs.realpath(wt).catch(() => null);
2946
+ if (real && real !== wt) {
2947
+ return {
2948
+ ok: false,
2949
+ kind: "symlink",
2950
+ message: `worktree ${wt} resolves through a symlink to ${real}`
2951
+ };
2952
+ }
2953
+ const linked = await listRegisteredWorktrees(main);
2954
+ const matched = linked.find((entry) => entry.path === wt);
2955
+ if (!matched) {
2956
+ return {
2957
+ ok: false,
2958
+ kind: "unlinked",
2959
+ message: `worktree ${wt} is not registered (git worktree list)`
2960
+ };
2961
+ }
2962
+ return { ok: true, path: wt, registered: !!matched };
2963
+ }
2964
+
2965
+ // src/tools/envelope.js
2966
+ import { randomBytes as randomBytes2 } from "node:crypto";
2967
+ var CONTRACT_VERSION = 2;
2968
+ function operationId(prefix = "op") {
2969
+ return `${prefix}-${Date.now().toString(36)}-${randomBytes2(4).toString("hex")}`;
2970
+ }
2971
+ function success(kind, data, options = {}) {
2972
+ if (typeof kind !== "string" || kind.length === 0) {
2973
+ throw new Error("envelope.success: kind must be a non-empty string");
2974
+ }
2975
+ return {
2976
+ contractVersion: CONTRACT_VERSION,
2977
+ ok: true,
2978
+ kind,
2979
+ operationId: options.operationId ?? operationId(kind),
2980
+ idempotent: options.idempotent !== false,
2981
+ data
2982
+ };
2983
+ }
2984
+ function failure(kind, message, options = {}) {
2985
+ if (typeof kind !== "string" || kind.length === 0) {
2986
+ throw new Error("envelope.failure: kind must be a non-empty string");
2987
+ }
2988
+ if (typeof message !== "string" || message.length === 0) {
2989
+ throw new Error("envelope.failure: message must be a non-empty string");
2990
+ }
2991
+ const details = options.details ?? {};
2992
+ return {
2993
+ contractVersion: CONTRACT_VERSION,
2994
+ ok: false,
2995
+ kind,
2996
+ operationId: options.operationId ?? operationId(`${kind}-err`),
2997
+ retryable: options.retryable === true,
2998
+ message,
2999
+ details
3000
+ };
3001
+ }
3002
+
3003
+ // src/tools/delivery-abandon.js
3004
+ import { readFile as readFile8 } from "node:fs/promises";
3005
+ function runGit2(args, cwd) {
3006
+ return spawnSync4("git", args, {
3007
+ cwd,
3008
+ encoding: "utf8",
3009
+ stdio: ["ignore", "pipe", "pipe"],
3010
+ env: process.env
3011
+ });
3012
+ }
3013
+ function defaultRemoveWorktree(repoRoot, path) {
3014
+ return runGit2(["worktree", "remove", path], repoRoot);
3015
+ }
3016
+ function defaultDeleteBranch(repoRoot, branch, expectedSha) {
3017
+ const args = ["update-ref", "-d", `refs/heads/${branch}`];
3018
+ if (expectedSha && /^[0-9a-f]{7,}$/i.test(expectedSha)) args.push(expectedSha);
3019
+ return runGit2(args, repoRoot);
3020
+ }
3021
+ function branchExists(repoRoot, branch) {
3022
+ return runGit2(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot).status === 0;
3023
+ }
3024
+ function remoteBranchHead(repoRoot, remote, branch) {
3025
+ const r = runGit2(["ls-remote", "--heads", remote, branch], repoRoot);
3026
+ if (r.status !== 0) return { ok: false, sha: null, present: false };
3027
+ const line = r.stdout.split("\n").find((row) => row.includes(`refs/heads/${branch}`));
3028
+ if (!line) return { ok: true, sha: null, present: false };
3029
+ return { ok: true, sha: line.split(/\s+/)[0], present: true };
3030
+ }
3031
+ function unpublishedAhead(repoRoot, remote, branch) {
3032
+ const r = runGit2(["rev-list", "--count", `${remote}/${branch}..${branch}`], repoRoot);
3033
+ if (r.status !== 0) return null;
3034
+ const n = parseInt(r.stdout.trim(), 10);
3035
+ return Number.isFinite(n) ? n : null;
3036
+ }
3037
+ function refuse(opId, kind, extras = {}) {
3038
+ return failure("abandon", kind, {
3039
+ operationId: opId,
3040
+ retryable: false,
3041
+ details: { kind, ...extras }
3042
+ });
3043
+ }
3044
+ async function readRunSnapshot(repoRoot, workflowId) {
3045
+ try {
3046
+ const common = await resolveGitCommonDir(repoRoot);
3047
+ const path = join10(opencodeShipStateDir(common), "runs", workflowId, "run.json");
3048
+ if (!existsSync8(path)) return null;
3049
+ return JSON.parse(await readFile8(path, "utf8"));
3050
+ } catch {
3051
+ return null;
3052
+ }
3053
+ }
3054
+ async function validateLiveAttempt({ deps, manifest, subject, opId }) {
3055
+ if (!manifest.prNumber) return refuse(opId, "missing-pr");
3056
+ if (!manifest.worktreePath) return refuse(opId, "missing-worktree-path");
3057
+ const pr = await deps.driver.readPullRequest({
3058
+ repo: deps.repoSlug,
3059
+ number: manifest.prNumber
3060
+ });
3061
+ if (pr.merged || pr.state === "MERGED") return refuse(opId, "pr-merged");
3062
+ if (pr.state !== "CLOSED") return refuse(opId, "pr-open");
3063
+ if (pr.headRefName && pr.headRefName !== manifest.branch) {
3064
+ return refuse(opId, "branch-mismatch", { expected: manifest.branch, received: pr.headRefName });
3065
+ }
3066
+ if (pr.baseRefName && pr.baseRefName !== manifest.baseBranch) {
3067
+ return refuse(opId, "base-mismatch", { expected: manifest.baseBranch, received: pr.baseRefName });
3068
+ }
3069
+ const linked = await validateLinkedWorktree(deps.repoRoot, manifest.worktreePath);
3070
+ if (!linked.ok) {
3071
+ return refuse(opId, "invalid-worktree", { reason: linked.kind, message: linked.message });
3072
+ }
3073
+ if (!isWorktreeClean(linked.path)) return refuse(opId, "dirty-worktree");
3074
+ if (isRebaseInProgress(linked.path)) return refuse(opId, "rebase-in-progress");
3075
+ const head = currentHead(linked.path);
3076
+ if (!head || head !== manifest.lastPrHeadSha || head !== pr.headSha) {
3077
+ return refuse(opId, "head-mismatch", {
3078
+ local: head ?? "",
3079
+ manifest: manifest.lastPrHeadSha ?? "",
3080
+ pr: pr.headSha ?? ""
3081
+ });
3082
+ }
3083
+ const remote = deps.remote ?? "origin";
3084
+ const remoteHead = remoteBranchHead(linked.path, remote, manifest.branch);
3085
+ if (remoteHead.ok && remoteHead.present && remoteHead.sha !== head) {
3086
+ return refuse(opId, "remote-diverged", { local: head, remote: remoteHead.sha });
3087
+ }
3088
+ const ahead = unpublishedAhead(linked.path, remote, manifest.branch);
3089
+ if (ahead !== null && ahead > 0) {
3090
+ return refuse(opId, "has-unpublished-commits", { ahead, branch: manifest.branch, remote });
3091
+ }
3092
+ if (manifest.workflowId) {
3093
+ const snapshot = await readRunSnapshot(deps.repoRoot, manifest.workflowId);
3094
+ if (snapshot?.state === "ready") return refuse(opId, "workflow-ready");
3095
+ if (snapshot?.state === "merged") return refuse(opId, "workflow-merged");
3096
+ }
3097
+ return {
3098
+ ok: true,
3099
+ intent: {
3100
+ schemaVersion: 1,
3101
+ taskId: manifest.taskId,
3102
+ issueNumber: manifest.issueNumber,
3103
+ prNumber: manifest.prNumber,
3104
+ branch: manifest.branch,
3105
+ worktreePath: linked.path,
3106
+ headSha: head,
3107
+ workflowId: manifest.workflowId ?? null,
3108
+ subject,
3109
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString()
3110
+ }
3111
+ };
3112
+ }
3113
+ async function resumeCleanup({ deps, intent, opId }) {
3114
+ const removeWorktree = deps.removeWorktree ?? defaultRemoveWorktree;
3115
+ const deleteBranch = deps.deleteBranch ?? defaultDeleteBranch;
3116
+ const removeManifest = deps.deleteManifest ?? deleteManifest;
3117
+ let removedWorktree = !existsSync8(intent.worktreePath);
3118
+ if (!removedWorktree) {
3119
+ let removed;
3120
+ try {
3121
+ removed = await Promise.resolve(removeWorktree(deps.repoRoot, intent.worktreePath));
3122
+ } catch (err) {
3123
+ return refuse(opId, "remove-failed", { stderr: String(err?.message ?? err) });
3124
+ }
3125
+ if (removed?.status !== 0 && existsSync8(intent.worktreePath)) {
3126
+ return refuse(opId, "remove-failed", { stderr: removed?.stderr ?? "" });
3127
+ }
3128
+ removedWorktree = !existsSync8(intent.worktreePath);
3129
+ }
3130
+ let deletedBranch = !branchExists(deps.repoRoot, intent.branch);
3131
+ if (!deletedBranch) {
3132
+ let deleted;
3133
+ try {
3134
+ deleted = await Promise.resolve(deleteBranch(deps.repoRoot, intent.branch, intent.headSha));
3135
+ } catch (err) {
3136
+ return refuse(opId, "branch-delete-failed", { stderr: String(err?.message ?? err) });
3137
+ }
3138
+ if (deleted?.status !== 0 && branchExists(deps.repoRoot, intent.branch)) {
3139
+ return refuse(opId, "branch-delete-failed", { stderr: deleted?.stderr ?? "" });
3140
+ }
3141
+ deletedBranch = !branchExists(deps.repoRoot, intent.branch);
3142
+ }
3143
+ try {
3144
+ await Promise.resolve(removeManifest(deps.repoRoot, intent.taskId));
3145
+ } catch (err) {
3146
+ return refuse(opId, "manifest-delete-failed", { stderr: String(err?.message ?? err) });
3147
+ }
3148
+ const remaining = await readManifest(deps.repoRoot, intent.taskId);
3149
+ const deletedManifest = remaining === null;
3150
+ const completion = {
3151
+ schemaVersion: 1,
3152
+ taskId: intent.taskId,
3153
+ intentHash: intent.intentHash,
3154
+ removedWorktree,
3155
+ deletedBranch,
3156
+ deletedManifest,
3157
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3158
+ };
3159
+ const published = await publishAbandonCompletion(deps.repoRoot, completion);
3160
+ if (!published.ok) return refuse(opId, published.kind);
3161
+ return success("abandon", {
3162
+ taskId: intent.taskId,
3163
+ intentHash: intent.intentHash,
3164
+ removedWorktree,
3165
+ deletedBranch,
3166
+ deletedManifest
3167
+ }, { operationId: opId, idempotent: published.idempotent === true });
3168
+ }
3169
+ function createAbandonTool(deps) {
3170
+ return async function abandon(input) {
3171
+ const opId = input.operationId ?? `abandon-${Date.now().toString(36)}`;
3172
+ const taskId = String(input.taskId ?? "");
3173
+ const subject = String(input.subject ?? "").trim();
3174
+ if (!taskId) return refuse(opId, "missing-input", { field: "taskId" });
3175
+ if (!subject) return refuse(opId, "missing-input", { field: "subject" });
3176
+ const existing = await readAbandon(deps.repoRoot, taskId);
3177
+ if (existing.completion) {
3178
+ return success("abandon", {
3179
+ taskId,
3180
+ intentHash: existing.completion.intentHash,
3181
+ removedWorktree: existing.completion.removedWorktree,
3182
+ deletedBranch: existing.completion.deletedBranch,
3183
+ deletedManifest: existing.completion.deletedManifest
3184
+ }, { operationId: opId, idempotent: true });
3185
+ }
3186
+ if (existing.intent) {
3187
+ if (existing.intent.subject !== subject || existing.intent.taskId !== taskId) {
3188
+ return refuse(opId, "abandon-conflict");
3189
+ }
3190
+ return resumeCleanup({ deps, intent: existing.intent, opId });
3191
+ }
3192
+ const manifest = await readManifest(deps.repoRoot, taskId);
3193
+ if (!manifest) return refuse(opId, "missing-manifest", { taskId });
3194
+ const validated = await validateLiveAttempt({ deps, manifest, subject, opId });
3195
+ if (!validated.ok) return validated;
3196
+ const publishedIntent = await publishAbandonIntent(deps.repoRoot, validated.intent);
3197
+ if (!publishedIntent.ok) return refuse(opId, publishedIntent.kind);
3198
+ return resumeCleanup({ deps, intent: publishedIntent.record, opId });
3199
+ };
3200
+ }
3201
+
2778
3202
  // src/version.js
2779
- import { readFileSync, existsSync as existsSync7 } from "node:fs";
2780
- import { dirname as dirname3, resolve as resolve9 } from "node:path";
3203
+ import { readFileSync, existsSync as existsSync9 } from "node:fs";
3204
+ import { dirname as dirname4, resolve as resolve10 } from "node:path";
2781
3205
  import { fileURLToPath } from "node:url";
2782
- var PACKAGE_VERSION = "1.1.7";
3206
+ var PACKAGE_VERSION = "1.1.8";
2783
3207
  var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
2784
3208
  export {
2785
3209
  ADAPTER_CONTRACT_VERSION,
@@ -2793,6 +3217,7 @@ export {
2793
3217
  bucketFor,
2794
3218
  canTransition,
2795
3219
  checkGates,
3220
+ createAbandonTool,
2796
3221
  createCleanupTool,
2797
3222
  createGhDriver,
2798
3223
  createGhStub,