@pitcher/cli-scripts 2.2.0 → 2.2.2

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 @@ interface PublishResult {
9
9
  installed?: string[];
10
10
  dryRun?: boolean;
11
11
  publishUrl?: string;
12
+ skipped?: boolean;
12
13
  }
13
14
  interface PublishOptions {
14
15
  folder?: string;
@@ -13,6 +13,25 @@ import { createCliError } from '../core/errors.js';
13
13
  import { emitEnvelope, log } from '../core/output.js';
14
14
  const PUBLISH_PATH = '/api/v1/apps/publish/';
15
15
  const INSTANCES_PATH = '/api/v1/instances';
16
+ /**
17
+ * The publish endpoint rejects re-publishing an existing version with a 400
18
+ * (`non_field_errors: ["An app release version X already exists."]`). That is a
19
+ * no-op, not a failure — the app is already live at that version — so deploy
20
+ * pipelines that re-run the publish step (workflow re-runs) must not hard-fail
21
+ * on it. Detect it so we can degrade to an idempotent success-with-warning.
22
+ */
23
+ function isVersionAlreadyExistsError(e) {
24
+ const err = e;
25
+ if (err?.response?.status !== 400)
26
+ return false;
27
+ // Only the version-release conflict — which the API reports in DRF
28
+ // `non_field_errors` — is a benign no-op. Don't scan the whole body: an
29
+ // unrelated 400 that merely mentions "already exists" (e.g. a field-level
30
+ // name/identifier conflict when creating a NEW app) is a real failure.
31
+ const nonField = err.response?.data?.non_field_errors;
32
+ const messages = Array.isArray(nonField) ? nonField : typeof nonField === 'string' ? [nonField] : [];
33
+ return messages.some((m) => typeof m === 'string' && /version .* already exists/i.test(m));
34
+ }
16
35
  function buildVersion(now) {
17
36
  const year = now.getFullYear();
18
37
  const month = String(now.getMonth() + 1).padStart(2, '0');
@@ -112,11 +131,28 @@ export async function runPublishApp(opts, deps = {}) {
112
131
  await zipDirectoryToFile(folderPath, zipPath);
113
132
  const form = new FormData();
114
133
  form.append('file', fs.createReadStream(zipPath));
115
- const res = await client.post(publishTarget, form, {
116
- headers: form.getHeaders(),
117
- maxBodyLength: Infinity,
118
- maxContentLength: Infinity,
119
- });
134
+ let res;
135
+ try {
136
+ res = await client.post(publishTarget, form, {
137
+ headers: form.getHeaders(),
138
+ maxBodyLength: Infinity,
139
+ maxContentLength: Infinity,
140
+ });
141
+ }
142
+ catch (publishError) {
143
+ // Idempotent re-publish: an existing version is already live, so this is
144
+ // a no-op, not a failure. Return ok with a warning instead of failing the
145
+ // command — otherwise deploy re-runs (which re-publish the same built
146
+ // version) break. Genuine errors still propagate to the outer catch.
147
+ if (isVersionAlreadyExistsError(publishError)) {
148
+ warnings.push(`Skipped publish: ${toCliError(publishError).message}`);
149
+ return ok({
150
+ app: existingAppJson ? { name: existingAppJson.name, version: nextVersion } : undefined,
151
+ skipped: true,
152
+ }, warnings);
153
+ }
154
+ throw publishError;
155
+ }
120
156
  const app = res.data?.app;
121
157
  const result = { app };
122
158
  if (!app?.name) {
@@ -1,4 +1,11 @@
1
1
  import { type AxiosInstance } from 'axios';
2
2
  import { type CliError } from './errors.js';
3
3
  export declare function makeClient(baseUrl: string, apiKey: string): AxiosInstance;
4
+ /**
5
+ * Build a human-readable message from a server error body. Handles DRF shapes:
6
+ * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
7
+ * (e.g. `{ version: ["already exists"] }`). Falls back to the raw body so the
8
+ * full server response is visible in logs instead of a bare status line.
9
+ */
10
+ export declare function extractApiMessage(data: unknown, fallback: string): string;
4
11
  export declare function toCliError(e: unknown): CliError;
@@ -9,12 +9,39 @@ export function makeClient(baseUrl, apiKey) {
9
9
  },
10
10
  });
11
11
  }
12
+ /**
13
+ * Build a human-readable message from a server error body. Handles DRF shapes:
14
+ * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
15
+ * (e.g. `{ version: ["already exists"] }`). Falls back to the raw body so the
16
+ * full server response is visible in logs instead of a bare status line.
17
+ */
18
+ export function extractApiMessage(data, fallback) {
19
+ if (data == null)
20
+ return fallback;
21
+ if (typeof data === 'string')
22
+ return data.trim() || fallback;
23
+ if (typeof data !== 'object')
24
+ return String(data);
25
+ const obj = data;
26
+ if (typeof obj.detail === 'string' && obj.detail)
27
+ return obj.detail;
28
+ if (typeof obj.error === 'string' && obj.error)
29
+ return obj.error;
30
+ const parts = [];
31
+ for (const [key, val] of Object.entries(obj)) {
32
+ const text = Array.isArray(val) ? val.join('; ') : typeof val === 'string' ? val : JSON.stringify(val);
33
+ parts.push(key === 'non_field_errors' ? text : `${key}: ${text}`);
34
+ }
35
+ if (parts.length)
36
+ return parts.join(' | ');
37
+ return fallback;
38
+ }
12
39
  export function toCliError(e) {
13
40
  if (isCliError(e))
14
41
  return e;
15
42
  const err = (e ?? {});
16
43
  const data = err.response?.data;
17
- const apiMessage = data?.detail || data?.error || err.message || 'Request failed';
44
+ const apiMessage = extractApiMessage(data, err.message || 'Request failed');
18
45
  const status = err.response?.status;
19
46
  if (typeof status === 'number') {
20
47
  if (status === 401 || status === 403)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",