@pitcher/cli-scripts 2.2.1 → 2.3.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.
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'url';
5
5
  import { dirname, join } from 'path';
6
6
  import { createPublishAppCommand } from './src/commands/publishApp.js';
7
7
  import { createDevSyncCommand } from './src/commands/devSync.js';
8
+ import { createFilesCommand } from './src/commands/files.js';
9
+ import { createFoldersCommand } from './src/commands/folders.js';
8
10
  const __filename = fileURLToPath(import.meta.url);
9
11
  const __dirname = dirname(__filename);
10
12
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -17,4 +19,6 @@ program
17
19
  .option('--quiet', 'Suppress non-essential (progress) output');
18
20
  program.addCommand(createPublishAppCommand());
19
21
  program.addCommand(createDevSyncCommand());
22
+ program.addCommand(createFilesCommand());
23
+ program.addCommand(createFoldersCommand());
20
24
  program.parseAsync(process.argv);
@@ -0,0 +1,46 @@
1
+ import { Command } from 'commander';
2
+ import axios, { type AxiosInstance } from 'axios';
3
+ import { type CommonOpts } from '../core/http.js';
4
+ import { type Envelope } from '../core/envelope.js';
5
+ export interface FilesDeps {
6
+ client?: AxiosInstance;
7
+ put?: typeof axios.put;
8
+ readFile?: (p: string) => Buffer;
9
+ }
10
+ export declare function runFilesList(opts: CommonOpts & {
11
+ instanceId?: string;
12
+ tags?: string;
13
+ idIn?: string;
14
+ page?: number;
15
+ pageSize?: number;
16
+ }, deps?: FilesDeps): Promise<Envelope<{
17
+ results: unknown[];
18
+ pagination: Record<string, unknown>;
19
+ }>>;
20
+ export declare function runFilesGet(opts: CommonOpts & {
21
+ id?: string;
22
+ }, deps?: FilesDeps): Promise<Envelope<unknown>>;
23
+ export declare function runFilesCreate(opts: CommonOpts & {
24
+ instanceId?: string;
25
+ name?: string;
26
+ folderId?: string;
27
+ filePath?: string;
28
+ sourceUrl?: string;
29
+ accessType?: string;
30
+ tags?: string;
31
+ metadata?: string;
32
+ appMetadata?: string;
33
+ }, deps?: FilesDeps): Promise<Envelope<unknown>>;
34
+ export declare function runFilesUpdate(opts: CommonOpts & {
35
+ id?: string;
36
+ instanceId?: string;
37
+ name?: string;
38
+ folderId?: string;
39
+ filePath?: string;
40
+ sourceUrl?: string;
41
+ accessType?: string;
42
+ tags?: string;
43
+ metadata?: string;
44
+ appMetadata?: string;
45
+ }, deps?: FilesDeps): Promise<Envelope<unknown>>;
46
+ export declare function createFilesCommand(): Command;
@@ -0,0 +1,170 @@
1
+ import { Command } from 'commander';
2
+ import { clientFor, toCliError } from '../core/http.js';
3
+ import { ok, fail } from '../core/envelope.js';
4
+ import { createCliError } from '../core/errors.js';
5
+ import { emitEnvelope } from '../core/output.js';
6
+ import { uploadFile } from '../core/upload.js';
7
+ import { listPageParams, listPaginationEcho } from '../core/pagination.js';
8
+ import { parseTags, parseJsonObject, validateAccessType, deepMerge, assertIntFlags } from '../schema/content.js';
9
+ export async function runFilesList(
10
+ // NOTE: no folderId — the Django files list has no folder filter (listing a
11
+ // folder's files is a folders-retrieve concern). tags filters via `tags__in`.
12
+ opts, deps = {}) {
13
+ try {
14
+ if (!opts.instanceId)
15
+ throw createCliError('VALIDATION', '--instance-id is required for files list');
16
+ assertIntFlags(opts);
17
+ const client = clientFor(opts, deps);
18
+ const params = { instance_id: opts.instanceId, ...listPageParams(opts) };
19
+ if (opts.idIn)
20
+ params.id__in = opts.idIn;
21
+ if (opts.tags)
22
+ params.tags__in = opts.tags;
23
+ const res = await client.get('/api/v1/files/', { params });
24
+ return ok({ results: res.data.results, pagination: listPaginationEcho(opts, res.data) });
25
+ }
26
+ catch (e) {
27
+ return fail(toCliError(e));
28
+ }
29
+ }
30
+ export async function runFilesGet(opts, deps = {}) {
31
+ try {
32
+ if (!opts.id)
33
+ throw createCliError('VALIDATION', 'file id is required');
34
+ const client = clientFor(opts, deps);
35
+ const res = await client.get(`/api/v1/files/${opts.id}`);
36
+ return ok(res.data);
37
+ }
38
+ catch (e) {
39
+ return fail(toCliError(e));
40
+ }
41
+ }
42
+ export async function runFilesCreate(opts, deps = {}) {
43
+ try {
44
+ if (!opts.instanceId)
45
+ throw createCliError('VALIDATION', '--instance-id is required for files create');
46
+ const client = clientFor(opts, deps);
47
+ const body = { instance_id: opts.instanceId };
48
+ if (opts.name)
49
+ body.name = opts.name;
50
+ if (opts.folderId)
51
+ body.folder_id = opts.folderId;
52
+ const accessType = validateAccessType(opts.accessType);
53
+ if (accessType)
54
+ body.access_type = accessType;
55
+ if (opts.tags)
56
+ body.tags = parseTags(opts.tags);
57
+ if (opts.metadata)
58
+ body.metadata = parseJsonObject(opts.metadata, 'metadata');
59
+ if (opts.appMetadata)
60
+ body.app_metadata = parseJsonObject(opts.appMetadata, 'app-metadata');
61
+ if (opts.filePath && opts.sourceUrl)
62
+ throw createCliError('VALIDATION', 'pass either --file or --source-url, not both');
63
+ if (opts.filePath) {
64
+ body.content_upload_id = await uploadFile(client, { instanceId: opts.instanceId, filePath: opts.filePath }, deps);
65
+ }
66
+ else if (opts.sourceUrl) {
67
+ body.url = opts.sourceUrl;
68
+ }
69
+ const res = await client.post('/api/v1/files/', body);
70
+ return ok(res.data);
71
+ }
72
+ catch (e) {
73
+ return fail(toCliError(e));
74
+ }
75
+ }
76
+ export async function runFilesUpdate(opts, deps = {}) {
77
+ try {
78
+ if (!opts.id)
79
+ throw createCliError('VALIDATION', 'file id is required');
80
+ const client = clientFor(opts, deps);
81
+ const body = {};
82
+ if (opts.name)
83
+ body.name = opts.name;
84
+ if (opts.folderId)
85
+ body.folder_id = opts.folderId;
86
+ const accessType = validateAccessType(opts.accessType);
87
+ if (accessType)
88
+ body.access_type = accessType;
89
+ if (opts.tags)
90
+ body.tags = parseTags(opts.tags);
91
+ // Metadata is a JSONField the PATCH replaces wholesale — read-merge-write.
92
+ const metaPatch = opts.metadata ? parseJsonObject(opts.metadata, 'metadata') : undefined;
93
+ const appMetaPatch = opts.appMetadata ? parseJsonObject(opts.appMetadata, 'app-metadata') : undefined;
94
+ if (metaPatch || appMetaPatch) {
95
+ const cur = (await client.get(`/api/v1/files/${opts.id}`)).data;
96
+ if (metaPatch)
97
+ body.metadata = deepMerge(cur.metadata, metaPatch);
98
+ if (appMetaPatch)
99
+ body.app_metadata = deepMerge(cur.app_metadata, appMetaPatch);
100
+ }
101
+ if (opts.filePath && opts.sourceUrl)
102
+ throw createCliError('VALIDATION', 'pass either --file or --source-url, not both');
103
+ if (opts.filePath) {
104
+ if (!opts.instanceId)
105
+ throw createCliError('VALIDATION', '--instance-id is required with --file');
106
+ body.content_upload_id = await uploadFile(client, { instanceId: opts.instanceId, filePath: opts.filePath }, deps);
107
+ }
108
+ else if (opts.sourceUrl) {
109
+ body.url = opts.sourceUrl;
110
+ }
111
+ const res = await client.patch(`/api/v1/files/${opts.id}`, body);
112
+ return ok(res.data);
113
+ }
114
+ catch (e) {
115
+ return fail(toCliError(e));
116
+ }
117
+ }
118
+ export function createFilesCommand() {
119
+ const cmd = new Command('files').description('manage Pitcher content files');
120
+ const withAuth = (c) => c
121
+ .option('--api-key <apiKey>', 'API Key for authentication')
122
+ .option('--org <organization>', 'Organization subdomain', 'dev')
123
+ .option('--url <url>', 'Explicit base URL override');
124
+ withAuth(cmd.command('list'))
125
+ .requiredOption('--instance-id <id>', 'Instance ID')
126
+ .option('--id-in <ids>', 'Comma-separated file IDs')
127
+ .option('--tags <tags>', 'Comma-separated tags (filters via tags__in)')
128
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
129
+ .option('--page-size <n>', 'Page size (max 500)', (v) => parseInt(v, 10))
130
+ .action(async (options, command) => {
131
+ const globals = command.optsWithGlobals();
132
+ emitEnvelope(await runFilesList({ ...options, quiet: globals.quiet }), globals);
133
+ });
134
+ withAuth(cmd.command('get'))
135
+ .argument('<id>', 'File ID')
136
+ .action(async (id, options, command) => {
137
+ const globals = command.optsWithGlobals();
138
+ emitEnvelope(await runFilesGet({ ...options, id, quiet: globals.quiet }), globals);
139
+ });
140
+ withAuth(cmd.command('create'))
141
+ .requiredOption('--instance-id <id>', 'Instance ID')
142
+ .option('--name <name>', 'File name')
143
+ .option('--folder-id <id>', 'Destination folder')
144
+ .option('--file <path>', 'Local file to upload')
145
+ .option('--source-url <url>', 'Link/WEB file source URL')
146
+ .option('--access-type <type>', 'public | restricted | personal')
147
+ .option('--tags <tags>', 'Comma-separated tags')
148
+ .option('--metadata <json>', 'metadata JSON object')
149
+ .option('--app-metadata <json>', 'app_metadata JSON object')
150
+ .action(async (options, command) => {
151
+ const globals = command.optsWithGlobals();
152
+ emitEnvelope(await runFilesCreate({ ...options, filePath: options.file, quiet: globals.quiet }), globals);
153
+ });
154
+ withAuth(cmd.command('update'))
155
+ .argument('<id>', 'File ID')
156
+ .option('--instance-id <id>', 'Instance ID (required only with --file)')
157
+ .option('--name <name>', 'File name')
158
+ .option('--folder-id <id>', 'Move to folder')
159
+ .option('--file <path>', 'Replace binary with local file')
160
+ .option('--source-url <url>', 'Replace with link/WEB source URL')
161
+ .option('--access-type <type>', 'public | restricted | personal')
162
+ .option('--tags <tags>', 'Comma-separated tags')
163
+ .option('--metadata <json>', 'metadata JSON object (deep-merged)')
164
+ .option('--app-metadata <json>', 'app_metadata JSON object (deep-merged)')
165
+ .action(async (id, options, command) => {
166
+ const globals = command.optsWithGlobals();
167
+ emitEnvelope(await runFilesUpdate({ ...options, id, filePath: options.file, quiet: globals.quiet }), globals);
168
+ });
169
+ return cmd;
170
+ }
@@ -0,0 +1,33 @@
1
+ import { Command } from 'commander';
2
+ import type { AxiosInstance } from 'axios';
3
+ import { type CommonOpts } from '../core/http.js';
4
+ import { type Envelope } from '../core/envelope.js';
5
+ export interface FoldersDeps {
6
+ client?: AxiosInstance;
7
+ }
8
+ export declare function runFoldersList(opts: CommonOpts & {
9
+ instanceId?: string;
10
+ page?: number;
11
+ pageSize?: number;
12
+ }, deps?: FoldersDeps): Promise<Envelope<{
13
+ results: unknown[];
14
+ pagination: Record<string, unknown>;
15
+ }>>;
16
+ export declare function runFoldersGet(opts: CommonOpts & {
17
+ id?: string;
18
+ root?: boolean;
19
+ instanceId?: string;
20
+ }, deps?: FoldersDeps): Promise<Envelope<unknown>>;
21
+ export declare function runFoldersCreate(opts: CommonOpts & {
22
+ name?: string;
23
+ parentFolderId?: string;
24
+ accessType?: string;
25
+ rules?: string;
26
+ }, deps?: FoldersDeps): Promise<Envelope<unknown>>;
27
+ export declare function runFoldersUpdate(opts: CommonOpts & {
28
+ id?: string;
29
+ name?: string;
30
+ accessType?: string;
31
+ rules?: string;
32
+ }, deps?: FoldersDeps): Promise<Envelope<unknown>>;
33
+ export declare function createFoldersCommand(): Command;
@@ -0,0 +1,123 @@
1
+ import { Command } from 'commander';
2
+ import { clientFor, toCliError } from '../core/http.js';
3
+ import { ok, fail } from '../core/envelope.js';
4
+ import { createCliError } from '../core/errors.js';
5
+ import { emitEnvelope } from '../core/output.js';
6
+ import { listPageParams, listPaginationEcho } from '../core/pagination.js';
7
+ import { parseJsonArray, validateAccessType, assertIntFlags } from '../schema/content.js';
8
+ export async function runFoldersList(opts, deps = {}) {
9
+ try {
10
+ if (!opts.instanceId)
11
+ throw createCliError('VALIDATION', '--instance-id is required for folders list');
12
+ assertIntFlags(opts);
13
+ const client = clientFor(opts, deps);
14
+ const params = { instance_id: opts.instanceId, ...listPageParams(opts) };
15
+ const res = await client.get('/api/v1/folders/', { params });
16
+ return ok({ results: res.data.results, pagination: listPaginationEcho(opts, res.data) });
17
+ }
18
+ catch (e) {
19
+ return fail(toCliError(e));
20
+ }
21
+ }
22
+ export async function runFoldersGet(opts, deps = {}) {
23
+ try {
24
+ if (opts.root && opts.id)
25
+ throw createCliError('VALIDATION', 'pass either a folder id or --root, not both');
26
+ if (!opts.root && !opts.id)
27
+ throw createCliError('VALIDATION', 'a folder id or --root is required');
28
+ const client = clientFor(opts, deps);
29
+ if (opts.root) {
30
+ if (!opts.instanceId)
31
+ throw createCliError('VALIDATION', '--instance-id is required with --root');
32
+ const res = await client.get(`/api/v1/folders/root/${opts.instanceId}`);
33
+ return ok(res.data);
34
+ }
35
+ const res = await client.get(`/api/v1/folders/${opts.id}`);
36
+ return ok(res.data);
37
+ }
38
+ catch (e) {
39
+ return fail(toCliError(e));
40
+ }
41
+ }
42
+ export async function runFoldersCreate(opts, deps = {}) {
43
+ try {
44
+ if (!opts.name)
45
+ throw createCliError('VALIDATION', '--name is required for folders create');
46
+ if (!opts.parentFolderId)
47
+ throw createCliError('VALIDATION', '--parent-folder-id is required for folders create');
48
+ const client = clientFor(opts, deps);
49
+ const body = { name: opts.name, parent_folder_id: opts.parentFolderId };
50
+ const accessType = validateAccessType(opts.accessType);
51
+ if (accessType)
52
+ body.access_type = accessType;
53
+ if (opts.rules)
54
+ body.rules = parseJsonArray(opts.rules, 'rules');
55
+ const res = await client.post('/api/v1/folders/', body);
56
+ return ok(res.data);
57
+ }
58
+ catch (e) {
59
+ return fail(toCliError(e));
60
+ }
61
+ }
62
+ export async function runFoldersUpdate(opts, deps = {}) {
63
+ try {
64
+ if (!opts.id)
65
+ throw createCliError('VALIDATION', 'folder id is required');
66
+ const client = clientFor(opts, deps);
67
+ const body = {};
68
+ if (opts.name)
69
+ body.name = opts.name;
70
+ const accessType = validateAccessType(opts.accessType);
71
+ if (accessType)
72
+ body.access_type = accessType;
73
+ if (opts.rules)
74
+ body.rules = parseJsonArray(opts.rules, 'rules');
75
+ const res = await client.patch(`/api/v1/folders/${opts.id}`, body);
76
+ return ok(res.data);
77
+ }
78
+ catch (e) {
79
+ return fail(toCliError(e));
80
+ }
81
+ }
82
+ export function createFoldersCommand() {
83
+ const cmd = new Command('folders').description('manage Pitcher content folders');
84
+ const withAuth = (c) => c
85
+ .option('--api-key <apiKey>', 'API Key for authentication')
86
+ .option('--org <organization>', 'Organization subdomain', 'dev')
87
+ .option('--url <url>', 'Explicit base URL override');
88
+ withAuth(cmd.command('list'))
89
+ .requiredOption('--instance-id <id>', 'Instance ID')
90
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
91
+ .option('--page-size <n>', 'Page size (max 500)', (v) => parseInt(v, 10))
92
+ .action(async (options, command) => {
93
+ const globals = command.optsWithGlobals();
94
+ emitEnvelope(await runFoldersList({ ...options, quiet: globals.quiet }), globals);
95
+ });
96
+ withAuth(cmd.command('get'))
97
+ .argument('[id]', 'Folder ID (omit and use --root for the instance root)')
98
+ .option('--root', 'Fetch the instance root folder')
99
+ .option('--instance-id <id>', 'Instance ID (required with --root)')
100
+ .action(async (id, options, command) => {
101
+ const globals = command.optsWithGlobals();
102
+ emitEnvelope(await runFoldersGet({ ...options, id, quiet: globals.quiet }), globals);
103
+ });
104
+ withAuth(cmd.command('create'))
105
+ .requiredOption('--name <name>', 'Folder name')
106
+ .requiredOption('--parent-folder-id <id>', 'Parent folder ID')
107
+ .option('--access-type <type>', 'public | restricted | personal')
108
+ .option('--rules <json>', 'Smart-folder rules JSON array')
109
+ .action(async (options, command) => {
110
+ const globals = command.optsWithGlobals();
111
+ emitEnvelope(await runFoldersCreate({ ...options, quiet: globals.quiet }), globals);
112
+ });
113
+ withAuth(cmd.command('update'))
114
+ .argument('<id>', 'Folder ID')
115
+ .option('--name <name>', 'Folder name')
116
+ .option('--access-type <type>', 'public | restricted | personal')
117
+ .option('--rules <json>', 'Smart-folder rules JSON array (replaces existing)')
118
+ .action(async (id, options, command) => {
119
+ const globals = command.optsWithGlobals();
120
+ emitEnvelope(await runFoldersUpdate({ ...options, id, quiet: globals.quiet }), globals);
121
+ });
122
+ return cmd;
123
+ }
@@ -24,9 +24,13 @@ function isVersionAlreadyExistsError(e) {
24
24
  const err = e;
25
25
  if (err?.response?.status !== 400)
26
26
  return false;
27
- const data = err.response?.data;
28
- const text = typeof data === 'string' ? data : JSON.stringify(data ?? '');
29
- return /already exists/i.test(text);
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));
30
34
  }
31
35
  function buildVersion(now) {
32
36
  const year = now.getFullYear();
@@ -6,10 +6,10 @@ export function resolveBaseUrl(opts) {
6
6
  parsed = new URL(opts.url);
7
7
  }
8
8
  catch {
9
- throw createCliError('VALIDATION', `Invalid --publish-url: ${opts.url}`);
9
+ throw createCliError('VALIDATION', `Invalid base URL: ${opts.url}`);
10
10
  }
11
11
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
12
- throw createCliError('VALIDATION', `--publish-url must be an http(s) URL: ${opts.url}`);
12
+ throw createCliError('VALIDATION', `Base URL must be an http(s) URL: ${opts.url}`);
13
13
  }
14
14
  return parsed.origin;
15
15
  }
@@ -1,6 +1,16 @@
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
+ export interface CommonOpts {
5
+ apiKey?: string;
6
+ org?: string;
7
+ url?: string;
8
+ quiet?: boolean;
9
+ }
10
+ /** Resolve auth + base URL into a client, or use an injected one (tests). */
11
+ export declare function clientFor(opts: CommonOpts, deps?: {
12
+ client?: AxiosInstance;
13
+ }): AxiosInstance;
4
14
  /**
5
15
  * Build a human-readable message from a server error body. Handles DRF shapes:
6
16
  * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
@@ -1,4 +1,6 @@
1
1
  import axios from 'axios';
2
+ import { resolveApiKey } from './auth.js';
3
+ import { resolveBaseUrl } from './baseUrl.js';
2
4
  import { createCliError, isCliError } from './errors.js';
3
5
  export function makeClient(baseUrl, apiKey) {
4
6
  return axios.create({
@@ -9,6 +11,10 @@ export function makeClient(baseUrl, apiKey) {
9
11
  },
10
12
  });
11
13
  }
14
+ /** Resolve auth + base URL into a client, or use an injected one (tests). */
15
+ export function clientFor(opts, deps = {}) {
16
+ return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }), resolveApiKey(opts));
17
+ }
12
18
  /**
13
19
  * Build a human-readable message from a server error body. Handles DRF shapes:
14
20
  * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
@@ -0,0 +1,19 @@
1
+ export declare const MAX_PAGE_SIZE = 500;
2
+ export interface PageOpts {
3
+ page?: number;
4
+ pageSize?: number;
5
+ }
6
+ /** Page query params to merge into a list request (clamped so sent === echoed). */
7
+ export declare function listPageParams(opts: PageOpts): Record<string, number>;
8
+ /**
9
+ * Pagination block echoed in the envelope. `count`/`next`/`previous` are
10
+ * server-truth. `page_size` is echoed ONLY when the caller set it (the clamped
11
+ * value we sent) — we don't fabricate a default, because the server can apply a
12
+ * different window than requested (e.g. Django ignores page_size when `id__in`
13
+ * is present), so a guessed value would misreport what actually happened.
14
+ */
15
+ export declare function listPaginationEcho(opts: PageOpts, res: {
16
+ count?: unknown;
17
+ next?: unknown;
18
+ previous?: unknown;
19
+ }): Record<string, unknown>;
@@ -0,0 +1,33 @@
1
+ // Shared list-pagination helpers so the page-size cap and default live in ONE
2
+ // place (files.ts and folders.ts both consume these — see PR #4956 review).
3
+ export const MAX_PAGE_SIZE = 500;
4
+ function clampPageSize(pageSize) {
5
+ return pageSize ? Math.min(pageSize, MAX_PAGE_SIZE) : undefined;
6
+ }
7
+ /** Page query params to merge into a list request (clamped so sent === echoed). */
8
+ export function listPageParams(opts) {
9
+ const params = {};
10
+ if (opts.page !== undefined)
11
+ params.page = opts.page;
12
+ const pageSize = clampPageSize(opts.pageSize);
13
+ if (pageSize !== undefined)
14
+ params.page_size = pageSize;
15
+ return params;
16
+ }
17
+ /**
18
+ * Pagination block echoed in the envelope. `count`/`next`/`previous` are
19
+ * server-truth. `page_size` is echoed ONLY when the caller set it (the clamped
20
+ * value we sent) — we don't fabricate a default, because the server can apply a
21
+ * different window than requested (e.g. Django ignores page_size when `id__in`
22
+ * is present), so a guessed value would misreport what actually happened.
23
+ */
24
+ export function listPaginationEcho(opts, res) {
25
+ const pageSize = clampPageSize(opts.pageSize);
26
+ return {
27
+ count: res.count,
28
+ next: res.next,
29
+ previous: res.previous,
30
+ page: opts.page ?? 1,
31
+ ...(pageSize !== undefined ? { page_size: pageSize } : {}),
32
+ };
33
+ }
@@ -0,0 +1,15 @@
1
+ import axios, { type AxiosInstance } from 'axios';
2
+ export interface UploadDeps {
3
+ put?: typeof axios.put;
4
+ readFile?: (p: string) => Buffer;
5
+ }
6
+ /**
7
+ * Upload a local file via the presigned-URL flow and return its content_upload_id.
8
+ * The S3 PUT uses a BARE axios call (not the Pitcher client) so it carries no
9
+ * x-api-key, and sends Content-Type equal to the server-signed `type` — omitting
10
+ * or mismatching it yields a 403 SignatureDoesNotMatch.
11
+ */
12
+ export declare function uploadFile(client: AxiosInstance, opts: {
13
+ instanceId: string;
14
+ filePath: string;
15
+ }, deps?: UploadDeps): Promise<string>;
@@ -0,0 +1,30 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import axios from 'axios';
4
+ import { createCliError } from './errors.js';
5
+ /**
6
+ * Upload a local file via the presigned-URL flow and return its content_upload_id.
7
+ * The S3 PUT uses a BARE axios call (not the Pitcher client) so it carries no
8
+ * x-api-key, and sends Content-Type equal to the server-signed `type` — omitting
9
+ * or mismatching it yields a 403 SignatureDoesNotMatch.
10
+ */
11
+ export async function uploadFile(client, opts, deps = {}) {
12
+ const readFile = deps.readFile ?? fs.readFileSync;
13
+ const put = deps.put ?? axios.put;
14
+ const resolved = path.resolve(opts.filePath);
15
+ const buffer = readFile(resolved);
16
+ const name = path.basename(resolved);
17
+ const createRes = await client.post('/api/v1/uploads/', {
18
+ name,
19
+ size: buffer.length,
20
+ instance_id: opts.instanceId,
21
+ });
22
+ const { id, presigned_url, type } = createRes.data;
23
+ // `type` is required: it's the server-signed Content-Type; sending undefined
24
+ // yields an opaque S3 403 SignatureDoesNotMatch instead of a clear error.
25
+ if (!id || !presigned_url || !type) {
26
+ throw createCliError('NETWORK', 'Upload init failed: /uploads response missing id, presigned_url, or type');
27
+ }
28
+ await put(presigned_url, buffer, { headers: { 'Content-Type': type } });
29
+ return id;
30
+ }
@@ -0,0 +1,9 @@
1
+ export declare function parseJsonObject(raw: string, flagName: string): Record<string, unknown>;
2
+ export declare function parseJsonArray(raw: string, flagName: string): unknown[];
3
+ export declare function parseTags(raw: string): string[];
4
+ export declare function validateAccessType(v: string | undefined): string | undefined;
5
+ export declare function assertIntFlags(opts: {
6
+ page?: number;
7
+ pageSize?: number;
8
+ }): void;
9
+ export declare function deepMerge(base: unknown, patch: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,57 @@
1
+ import { createCliError } from '../core/errors.js';
2
+ const ACCESS_TYPES = ['public', 'restricted', 'personal'];
3
+ function isPlainObject(v) {
4
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
5
+ }
6
+ function parseJson(raw, flagName) {
7
+ try {
8
+ return JSON.parse(raw);
9
+ }
10
+ catch {
11
+ throw createCliError('VALIDATION', `--${flagName} must be valid JSON`);
12
+ }
13
+ }
14
+ export function parseJsonObject(raw, flagName) {
15
+ const parsed = parseJson(raw, flagName);
16
+ if (!isPlainObject(parsed))
17
+ throw createCliError('VALIDATION', `--${flagName} must be a JSON object`);
18
+ return parsed;
19
+ }
20
+ export function parseJsonArray(raw, flagName) {
21
+ const parsed = parseJson(raw, flagName);
22
+ if (!Array.isArray(parsed))
23
+ throw createCliError('VALIDATION', `--${flagName} must be a JSON array`);
24
+ return parsed;
25
+ }
26
+ export function parseTags(raw) {
27
+ return raw.split(',').map((t) => t.trim()).filter(Boolean);
28
+ }
29
+ export function validateAccessType(v) {
30
+ if (v === undefined)
31
+ return undefined;
32
+ if (!ACCESS_TYPES.includes(v)) {
33
+ throw createCliError('VALIDATION', `--access-type must be one of: ${ACCESS_TYPES.join(', ')}`);
34
+ }
35
+ return v;
36
+ }
37
+ // Guards against NaN reaching the request (commander's parseInt yields NaN on
38
+ // non-numeric input, e.g. `--page abc`).
39
+ export function assertIntFlags(opts) {
40
+ const checks = [
41
+ ['page', opts.page],
42
+ ['page-size', opts.pageSize],
43
+ ];
44
+ for (const [flag, val] of checks) {
45
+ if (val !== undefined && (!Number.isInteger(val) || val < 1)) {
46
+ throw createCliError('VALIDATION', `--${flag} must be a positive integer`);
47
+ }
48
+ }
49
+ }
50
+ export function deepMerge(base, patch) {
51
+ const out = isPlainObject(base) ? { ...base } : {};
52
+ for (const [k, v] of Object.entries(patch)) {
53
+ const cur = out[k];
54
+ out[k] = isPlainObject(v) && isPlainObject(cur) ? deepMerge(cur, v) : v;
55
+ }
56
+ return out;
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",
@@ -26,13 +26,13 @@
26
26
  ],
27
27
  "devDependencies": {
28
28
  "@types/archiver": "^7.0.0",
29
- "@types/node": "^24.10.9",
29
+ "@types/node": "^24.13.2",
30
30
  "typescript": "^6.0.3",
31
31
  "vitest": "^4.1.9"
32
32
  },
33
33
  "dependencies": {
34
34
  "archiver": "^7.0.1",
35
- "axios": "^1.16.0",
35
+ "axios": "^1.18.1",
36
36
  "chokidar": "^5.0.0",
37
37
  "commander": "^14.0.3",
38
38
  "form-data": "^4.0.6"