@git.zone/cli 6.7.3 → 6.9.0

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.
@@ -9,6 +9,7 @@ const supportedPnpmVersions = [
9
9
  "11.25.0",
10
10
  "12.1.0",
11
11
  "12.2.1",
12
+ "12.3.0",
12
13
  "12.3.4",
13
14
  ] as const;
14
15
  const pnpmHelpRequirements = {
@@ -49,6 +50,19 @@ const pnpmHelpRequirements = {
49
50
  "[possible values: public, restricted]",
50
51
  ],
51
52
  },
53
+ "12.3.0": {
54
+ pack: ["Usage: pnpm pack [OPTIONS]", "--out <OUT>", "--json"],
55
+ publish: [
56
+ "Usage: pnpm publish [OPTIONS] [PACKAGE]",
57
+ "Tarball or directory to publish",
58
+ "--no-git-checks",
59
+ "--ignore-scripts",
60
+ "--json",
61
+ "--tag <TAG>",
62
+ "--access <ACCESS>",
63
+ "[possible values: public, restricted]",
64
+ ],
65
+ },
52
66
  "12.3.4": {
53
67
  pack: ["Usage: pnpm pack [OPTIONS]", "--out <OUT>", "--json"],
54
68
  publish: [
@@ -70,10 +84,31 @@ const publishCommandTimeoutMs = 5 * 60_000;
70
84
  const packageNameRegex =
71
85
  /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
72
86
 
87
+ export function assertNpmPackageName(value: unknown): asserts value is string {
88
+ if (typeof value !== "string" || !packageNameRegex.test(value)) {
89
+ throw new Error("Invalid release package name.");
90
+ }
91
+ }
92
+
73
93
  export interface IPnpmReleaseCapability {
74
- version: (typeof supportedPnpmVersions)[number];
94
+ readonly version: (typeof supportedPnpmVersions)[number];
75
95
  }
76
96
 
97
+ export const buildQualifiedPnpmArgs = (
98
+ capabilityArg: IPnpmReleaseCapability | undefined,
99
+ argsArg: readonly string[],
100
+ ): string[] => {
101
+ if (
102
+ !capabilityArg ||
103
+ !supportedPnpmVersions.includes(capabilityArg.version)
104
+ ) {
105
+ throw new Error(
106
+ "npm release commands require a qualified pnpm invocation.",
107
+ );
108
+ }
109
+ return ["with", capabilityArg.version, ...argsArg];
110
+ };
111
+
77
112
  export type TNpmArtifactProbeStatus =
78
113
  "absent" | "exact" | "conflict" | "inconclusive";
79
114
 
@@ -163,21 +198,28 @@ export const assertPnpmReleaseCapability = async (
163
198
  );
164
199
  }
165
200
  const requirements = pnpmHelpRequirements[supportedVersion];
201
+ const capability = Object.freeze({ version: supportedVersion });
166
202
 
167
- const packHelp = await smartshellArg.execSpawn("pnpm", ["pack", "--help"], {
168
- cwd: cwdArg,
169
- silent: true,
170
- timeout: capabilityCommandTimeoutMs,
171
- timeoutKillGraceMs: 5_000,
172
- });
203
+ const packHelp = await smartshellArg.execSpawn(
204
+ "pnpm",
205
+ buildQualifiedPnpmArgs(capability, ["pack", "--help"]),
206
+ {
207
+ cwd: cwdArg,
208
+ silent: true,
209
+ timeout: capabilityCommandTimeoutMs,
210
+ timeoutKillGraceMs: 5_000,
211
+ },
212
+ );
173
213
  if (packHelp.exitCode !== 0) {
174
- throw new Error("Unable to inspect pnpm pack capabilities.");
214
+ throw new Error(
215
+ "Unable to inspect the selected pnpm pack capabilities. Exact release commands require standalone pnpm with support; Corepack launchers are unsupported.",
216
+ );
175
217
  }
176
218
  requireHelpFragments(packHelp.combinedOutput, requirements.pack, "pack");
177
219
 
178
220
  const publishHelp = await smartshellArg.execSpawn(
179
221
  "pnpm",
180
- ["publish", "--help"],
222
+ buildQualifiedPnpmArgs(capability, ["publish", "--help"]),
181
223
  {
182
224
  cwd: cwdArg,
183
225
  silent: true,
@@ -193,7 +235,7 @@ export const assertPnpmReleaseCapability = async (
193
235
  requirements.publish,
194
236
  "publish",
195
237
  );
196
- return { version: supportedVersion };
238
+ return capability;
197
239
  };
198
240
 
199
241
  /**
@@ -352,6 +394,7 @@ export const packNpmArtifact = async (
352
394
  cwdArg: string,
353
395
  destinationDirectoryArg: string,
354
396
  expectedVersionArg: string,
397
+ capabilityArg: IPnpmReleaseCapability | undefined,
355
398
  fileArg: string = releaseArtifactFileName,
356
399
  ): Promise<IReleaseArtifact> => {
357
400
  if (
@@ -369,7 +412,12 @@ export const packNpmArtifact = async (
369
412
  const artifactPath = plugins.path.join(destinationDirectoryArg, fileArg);
370
413
  const result = await smartshellArg.execSpawn(
371
414
  "pnpm",
372
- ["pack", "--out", artifactPath, "--json"],
415
+ buildQualifiedPnpmArgs(capabilityArg, [
416
+ "pack",
417
+ "--out",
418
+ artifactPath,
419
+ "--json",
420
+ ]),
373
421
  {
374
422
  cwd: cwdArg,
375
423
  timeout: packCommandTimeoutMs,
@@ -464,10 +512,14 @@ export const publishNpmArtifact = async (
464
512
  cwdArg: string,
465
513
  artifactPathArg: string,
466
514
  registryArg: string,
515
+ capabilityArg: IPnpmReleaseCapability | undefined,
467
516
  ): Promise<{ exitCode: number; output: string }> => {
468
517
  const result = await smartshellArg.execSpawn(
469
518
  "pnpm",
470
- buildPnpmPublishArgs(artifactPathArg, registryArg),
519
+ buildQualifiedPnpmArgs(
520
+ capabilityArg,
521
+ buildPnpmPublishArgs(artifactPathArg, registryArg),
522
+ ),
471
523
  {
472
524
  cwd: cwdArg,
473
525
  timeout: publishCommandTimeoutMs,
@@ -500,6 +552,30 @@ const cancelResponseBody = (responseArg: Response): void => {
500
552
  }
501
553
  };
502
554
 
555
+ /** Recovery without an original artifact must never adopt a published version. */
556
+ export const assertNpmVersionAbsent = async (
557
+ registryArg: string,
558
+ packageNameArg: string,
559
+ versionArg: string,
560
+ optionsArg: INpmArtifactProbeOptions = {},
561
+ ): Promise<void> => {
562
+ const registry = normalizeNpmRegistryUrl(registryArg);
563
+ assertNpmPackageName(packageNameArg);
564
+ const response = await fetchWithoutRedirects(
565
+ new URL(
566
+ `${registry}/${encodeURIComponent(packageNameArg)}/${encodeURIComponent(versionArg)}`,
567
+ ),
568
+ "application/json",
569
+ optionsArg,
570
+ );
571
+ cancelResponseBody(response);
572
+ if (response.status !== 404) {
573
+ throw new Error(
574
+ `Cannot prove ${packageNameArg}@${versionArg} is unpublished at ${registry} (HTTP ${response.status}).`,
575
+ );
576
+ }
577
+ };
578
+
503
579
  const readBoundedResponse = async (
504
580
  responseArg: Response,
505
581
  maximumSizeArg: number,
@@ -0,0 +1,308 @@
1
+ import * as plugins from "./mod.plugins.js";
2
+ import type { IResolvedReleaseWorkflow } from "../helpers.workflow.js";
3
+ import { readPendingChangelog } from "../helpers.changelog.js";
4
+ import { InterProcessLock } from "../mod_services/classes.interprocesslock.js";
5
+ import { ReleaseJournalStore } from "./classes.releasejournal.js";
6
+ import {
7
+ releaseGitEnv,
8
+ type IReleaseBranchContext,
9
+ } from "./helpers.releasebranch.js";
10
+ import {
11
+ assertNpmVersionAbsent,
12
+ assertNpmPackageName,
13
+ hashReleaseDestination,
14
+ } from "./helpers.npmartifact.js";
15
+ import { canonicalReleaseJson } from "./helpers.tsdockerprotocol.js";
16
+
17
+ interface IRecoveryRefs {
18
+ mainOid: string;
19
+ tagOid: string;
20
+ }
21
+
22
+ export interface IReleasePreparationRecovery {
23
+ smartshell: plugins.smartshell.Smartshell;
24
+ workflow: IResolvedReleaseWorkflow;
25
+ branch: IReleaseBranchContext;
26
+ refs: IRecoveryRefs;
27
+ version: string;
28
+ store: ReleaseJournalStore;
29
+ }
30
+
31
+ const git = async (
32
+ options: IReleasePreparationRecovery,
33
+ args: string[],
34
+ cwd = options.branch.sourceCwd,
35
+ ) => {
36
+ const result = await options.smartshell.execSpawn("git", args, {
37
+ cwd,
38
+ env: releaseGitEnv,
39
+ silent: true,
40
+ timeout: 60_000,
41
+ timeoutKillGraceMs: 5_000,
42
+ });
43
+ if (result.exitCode !== 0)
44
+ throw new Error(`Release recovery Git validation failed: ${args[0]}.`);
45
+ return result.stdout.trim();
46
+ };
47
+
48
+ export const assertPackageReleaseRecovery = async (
49
+ options: IReleasePreparationRecovery,
50
+ ): Promise<string> => {
51
+ const { workflow, branch, version } = options;
52
+ if (
53
+ workflow.targets.length !== 2 ||
54
+ !workflow.targets.includes("git") ||
55
+ !workflow.targets.includes("npm") ||
56
+ workflow.npmPackageSource !== "root" ||
57
+ !workflow.pushBranch ||
58
+ !workflow.pushTags ||
59
+ branch.sourceBranch !== "main" ||
60
+ !branch.pushUrl ||
61
+ !branch.remoteMainOid
62
+ ) {
63
+ throw new Error(
64
+ "Release recover requires configured Git and root npm targets from clean main.",
65
+ );
66
+ }
67
+ const manifest = JSON.parse(
68
+ await plugins.fs.readFile(
69
+ plugins.path.join(branch.sourceCwd, "package.json"),
70
+ "utf8",
71
+ ),
72
+ );
73
+ if (manifest.version !== version || typeof manifest.name !== "string") {
74
+ throw new Error(
75
+ "Recovery package identity does not match the existing release version.",
76
+ );
77
+ }
78
+ assertNpmPackageName(manifest.name);
79
+ const pending = await readPendingChangelog(
80
+ plugins.path.join(branch.sourceCwd, workflow.changelogFile),
81
+ workflow.changelogPendingSection,
82
+ );
83
+ const changelog = await plugins.fs.readFile(
84
+ plugins.path.join(branch.sourceCwd, workflow.changelogFile),
85
+ "utf8",
86
+ );
87
+ const headings = changelog
88
+ .split(/\r?\n/)
89
+ .filter(
90
+ (line) =>
91
+ /^##\s/.test(line) && line !== `## ${workflow.changelogPendingSection}`,
92
+ );
93
+ if (
94
+ !pending.isEmpty ||
95
+ !new RegExp(
96
+ `^## \\d{4}-\\d{2}-\\d{2} - ${version.replace(/\./g, "\\.")}\\s*$`,
97
+ ).test(headings[0] ?? "")
98
+ ) {
99
+ throw new Error(
100
+ "Recovery requires the completed changelog section for this version and no Pending changes.",
101
+ );
102
+ }
103
+ if (
104
+ (await git(options, [
105
+ "show",
106
+ "-s",
107
+ "--format=%s",
108
+ options.refs.mainOid,
109
+ ])) !== `v${version}` ||
110
+ (
111
+ await git(options, [
112
+ "rev-list",
113
+ "--parents",
114
+ "-n",
115
+ "1",
116
+ options.refs.mainOid,
117
+ ])
118
+ ).split(" ").length !== 2
119
+ ) {
120
+ throw new Error("Recovery requires an existing non-merge release commit.");
121
+ }
122
+ if (
123
+ await git(options, [
124
+ "diff",
125
+ `${options.refs.mainOid}^`,
126
+ options.refs.mainOid,
127
+ "--",
128
+ ".smartconfig.json",
129
+ ])
130
+ ) {
131
+ throw new Error(
132
+ "Release configuration changed inside the release metadata commit.",
133
+ );
134
+ }
135
+ return manifest.name;
136
+ };
137
+
138
+ export const assertRecoveryUnpublished = async (
139
+ options: IReleasePreparationRecovery,
140
+ packageName: string,
141
+ ): Promise<void> => {
142
+ const output = await git(options, [
143
+ "ls-remote",
144
+ options.branch.pushUrl!,
145
+ `refs/tags/v${options.version}`,
146
+ `refs/tags/v${options.version}^{}`,
147
+ ]);
148
+ if (output)
149
+ throw new Error(
150
+ "Recovery cannot adopt an existing remote release tag. Use its original journal.",
151
+ );
152
+ for (const registry of options.workflow.npmRegistries)
153
+ await assertNpmVersionAbsent(registry, packageName, options.version);
154
+ };
155
+
156
+ const preparationBinding = (options: IReleasePreparationRecovery) => ({
157
+ kind: "gitzone-package-release-preparation",
158
+ schemaVersion: 1,
159
+ version: options.version,
160
+ ...options.refs,
161
+ destinationHash: hashReleaseDestination(options.branch.pushUrl!),
162
+ expectedRemoteMainOid: options.branch.remoteMainOid,
163
+ // Capture resolved configuration, including build/test commands, so retries
164
+ // cannot silently prepare a different release.
165
+ workflow: JSON.parse(
166
+ JSON.stringify({ ...options.workflow, confirmation: "auto" }),
167
+ ),
168
+ });
169
+
170
+ const bindPreparation = async (
171
+ path: string,
172
+ expected: string,
173
+ ): Promise<void> => {
174
+ try {
175
+ const stat = await plugins.fs.lstat(path);
176
+ if (
177
+ !stat.isFile() ||
178
+ stat.isSymbolicLink() ||
179
+ stat.size > 128 * 1024 ||
180
+ (await plugins.fs.readFile(path, "utf8")) !== expected
181
+ ) {
182
+ throw new Error(
183
+ "Release recovery identity or configuration differs from its original preparation.",
184
+ );
185
+ }
186
+ } catch (error) {
187
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
188
+ const handle = await plugins.fs.open(path, "wx", 0o600);
189
+ try {
190
+ await handle.writeFile(expected, "utf8");
191
+ await handle.sync();
192
+ } finally {
193
+ await handle.close();
194
+ }
195
+ const directory = await plugins.fs.open(plugins.path.dirname(path), "r");
196
+ try {
197
+ await directory.sync();
198
+ } finally {
199
+ await directory.close();
200
+ }
201
+ }
202
+ };
203
+
204
+ /** Build and seal one unpublished, already tagged package without changing refs. */
205
+ export const withReleasePreparationRecovery = async <T>(
206
+ options: IReleasePreparationRecovery,
207
+ ensureState: () => Promise<void>,
208
+ prepare: (
209
+ checkout: string,
210
+ ensureCheckout: () => Promise<void>,
211
+ ) => Promise<T>,
212
+ ): Promise<T> => {
213
+ if (!options.branch.planAncestryVerified) {
214
+ throw new Error(
215
+ "Release recovery requires verified remote ancestry before preparation.",
216
+ );
217
+ }
218
+ const directory = options.store.getReleaseDirectory(options.version);
219
+ return new InterProcessLock({
220
+ lockPath: `${directory}.prepare.lock`,
221
+ description: `Release v${options.version} preparation`,
222
+ }).runExclusive(async () => {
223
+ await ensureState();
224
+ try {
225
+ await plugins.fs.lstat(directory);
226
+ throw new Error(
227
+ "Release journal already exists. Use release resume; do not rebuild its artifact.",
228
+ );
229
+ } catch (error) {
230
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
231
+ }
232
+ await bindPreparation(
233
+ `${directory}.preparation.json`,
234
+ `${canonicalReleaseJson(preparationBinding(options))}\n`,
235
+ );
236
+ const root = await plugins.fs.mkdtemp(
237
+ plugins.path.join(plugins.os.tmpdir(), "gitzone-release-prepare-"),
238
+ );
239
+ const checkout = plugins.path.join(root, "source");
240
+ let added = false;
241
+ try {
242
+ await git(options, [
243
+ "worktree",
244
+ "add",
245
+ "--detach",
246
+ checkout,
247
+ options.refs.mainOid,
248
+ ]);
249
+ added = true;
250
+ const install = await options.smartshell.execSpawn(
251
+ "pnpm",
252
+ ["install", "--frozen-lockfile"],
253
+ {
254
+ cwd: checkout,
255
+ timeout: 10 * 60_000,
256
+ timeoutKillGraceMs: 5_000,
257
+ },
258
+ );
259
+ if (install.exitCode !== 0)
260
+ throw new Error("Release recovery dependency installation failed.");
261
+ if (options.workflow.runTests)
262
+ await run(options, checkout, options.workflow.testCommand, "tests");
263
+ if (options.workflow.runBuild)
264
+ await run(options, checkout, options.workflow.buildCommand, "build");
265
+ await assertCheckout(options, checkout);
266
+ await ensureState();
267
+ const result = await prepare(checkout, () =>
268
+ assertCheckout(options, checkout),
269
+ );
270
+ await assertCheckout(options, checkout);
271
+ return result;
272
+ } finally {
273
+ // This path is a uniquely owned disposable checkout, never the user's
274
+ // source tree or runtime data. Keep registration evidence if cleanup fails.
275
+ if (added)
276
+ await git(options, ["worktree", "remove", "--force", checkout]);
277
+ await plugins.fs.rm(root, { recursive: true, force: true });
278
+ }
279
+ });
280
+ };
281
+
282
+ const run = async (
283
+ options: IReleasePreparationRecovery,
284
+ cwd: string,
285
+ command: string,
286
+ phase: string,
287
+ ) => {
288
+ const result = await options.smartshell.exec(command, { cwd });
289
+ if (result.exitCode !== 0)
290
+ throw new Error(`Release recovery ${phase} failed.`);
291
+ };
292
+
293
+ const assertCheckout = async (
294
+ options: IReleasePreparationRecovery,
295
+ checkout: string,
296
+ ) => {
297
+ if (
298
+ (await git(options, ["rev-parse", "HEAD"], checkout)) !==
299
+ options.refs.mainOid ||
300
+ (await git(
301
+ options,
302
+ ["status", "--porcelain", "--untracked-files=all"],
303
+ checkout,
304
+ ))
305
+ ) {
306
+ throw new Error("Release recovery build changed its exact tracked source.");
307
+ }
308
+ };
@@ -18,6 +18,8 @@ import {
18
18
  publishNpmArtifact,
19
19
  verifyStoredNpmArtifact,
20
20
  waitForAnonymousNpmArtifact,
21
+ buildQualifiedPnpmArgs,
22
+ type IPnpmReleaseCapability,
21
23
  type INpmArtifactProbeOptions,
22
24
  type INpmArtifactProbeResult,
23
25
  } from "./helpers.npmartifact.js";
@@ -85,6 +87,7 @@ interface IGitProbeResult {
85
87
  }
86
88
 
87
89
  export interface IReleasePublicationOptions {
90
+ pnpmCapability?: IPnpmReleaseCapability;
88
91
  smartshell: plugins.smartshell.Smartshell;
89
92
  cwd: string;
90
93
  store: ReleaseJournalStore;
@@ -713,12 +716,15 @@ const executeNpmRegistry = async (
713
716
  artifact.file,
714
717
  );
715
718
  await verifyStoredNpmArtifact(artifactPath, artifact);
719
+ // Reject a missing invocation before claiming a potentially publishing target.
720
+ buildQualifiedPnpmArgs(optionsArg.pnpmCapability, []);
716
721
  journal = await claimTarget(optionsArg.store, journal, selector);
717
722
  const publishResult = await publishNpmArtifact(
718
723
  optionsArg.smartshell,
719
724
  optionsArg.cwd,
720
725
  artifactPath,
721
726
  registryArg,
727
+ optionsArg.pnpmCapability,
722
728
  );
723
729
  probe = await probeNpm(
724
730
  journal,