@pitcher/cli-scripts 2.1.1 → 2.2.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 +13 -12
- package/dist/src/commands/devSync.d.ts +35 -0
- package/dist/src/commands/devSync.js +191 -0
- package/dist/src/commands/publishApp.d.ts +30 -0
- package/dist/src/commands/publishApp.js +181 -0
- package/dist/src/core/archive.d.ts +2 -0
- package/dist/src/core/archive.js +25 -0
- package/dist/src/core/auth.d.ts +3 -0
- package/dist/src/core/auth.js +10 -0
- package/dist/src/core/baseUrl.d.ts +4 -0
- package/dist/src/core/baseUrl.js +18 -0
- package/dist/src/core/envelope.d.ts +13 -0
- package/dist/src/core/envelope.js +10 -0
- package/dist/src/core/errors.d.ts +18 -0
- package/dist/src/core/errors.js +25 -0
- package/dist/src/core/http.d.ts +4 -0
- package/dist/src/core/http.js +33 -0
- package/dist/src/core/output.d.ts +22 -0
- package/dist/src/core/output.js +45 -0
- package/package.json +9 -6
- package/dist/copyInstanceFiles.d.ts +0 -2
- package/dist/copyInstanceFiles.js +0 -17
- package/dist/package.json +0 -40
- package/dist/publishApp.d.ts +0 -2
- package/dist/publishApp.js +0 -93
- package/dist/src/copyInstanceFiles.d.ts +0 -2
- package/dist/src/copyInstanceFiles.js +0 -17
- package/dist/src/devSync.d.ts +0 -2
- package/dist/src/devSync.js +0 -251
- package/dist/src/publishApp.d.ts +0 -2
- package/dist/src/publishApp.js +0 -257
package/dist/index.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command } from
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { createPublishAppCommand } from './src/commands/publishApp.js';
|
|
7
|
+
import { createDevSyncCommand } from './src/commands/devSync.js';
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
9
|
const __dirname = dirname(__filename);
|
|
10
|
-
const pkg = JSON.parse(readFileSync(join(__dirname,
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
11
11
|
const program = new Command();
|
|
12
12
|
program
|
|
13
|
-
.name(
|
|
14
|
-
.description(
|
|
15
|
-
.version(pkg.version)
|
|
16
|
-
|
|
13
|
+
.name('p')
|
|
14
|
+
.description('The Pitcher CLI for various tasks in its ecosystem.')
|
|
15
|
+
.version(pkg.version)
|
|
16
|
+
.option('--output <format>', 'Output format: "text" or "json" (auto-json when stdout is not a TTY)')
|
|
17
|
+
.option('--quiet', 'Suppress non-essential (progress) output');
|
|
17
18
|
program.addCommand(createPublishAppCommand());
|
|
18
19
|
program.addCommand(createDevSyncCommand());
|
|
19
|
-
program.
|
|
20
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { AxiosInstance } from 'axios';
|
|
3
|
+
import { type Envelope } from '../core/envelope.js';
|
|
4
|
+
interface AppJson {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
interface SyncResponse {
|
|
10
|
+
success: boolean;
|
|
11
|
+
message: string;
|
|
12
|
+
dev_version: string;
|
|
13
|
+
original_version: string;
|
|
14
|
+
files_uploaded: number;
|
|
15
|
+
s3_prefix: string;
|
|
16
|
+
}
|
|
17
|
+
interface DevSyncOptions {
|
|
18
|
+
fileId: string;
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
org?: string;
|
|
21
|
+
quiet?: boolean;
|
|
22
|
+
output?: string;
|
|
23
|
+
}
|
|
24
|
+
interface DevSyncDeps {
|
|
25
|
+
client?: AxiosInstance;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function findDistPath(cwd?: string): string;
|
|
29
|
+
export declare function readAppJson(distPath: string): AppJson;
|
|
30
|
+
export declare function runDevSync(opts: DevSyncOptions, deps?: DevSyncDeps): Promise<Envelope<SyncResponse>>;
|
|
31
|
+
export declare function runRevert(opts: DevSyncOptions, deps?: DevSyncDeps): Promise<Envelope<{
|
|
32
|
+
restored_version?: string;
|
|
33
|
+
}>>;
|
|
34
|
+
export declare function createDevSyncCommand(): Command;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import FormData from 'form-data';
|
|
5
|
+
import chokidar from 'chokidar';
|
|
6
|
+
import { resolveApiKey } from '../core/auth.js';
|
|
7
|
+
import { resolveBaseUrl } from '../core/baseUrl.js';
|
|
8
|
+
import { makeClient, toCliError } from '../core/http.js';
|
|
9
|
+
import { zipDirectoryToBuffer } from '../core/archive.js';
|
|
10
|
+
import { ok, fail } from '../core/envelope.js';
|
|
11
|
+
import { createCliError } from '../core/errors.js';
|
|
12
|
+
import { emitEnvelope, log } from '../core/output.js';
|
|
13
|
+
const SYNC_ENDPOINT = '/core/api/protected/development/s3-app-sync';
|
|
14
|
+
export function findDistPath(cwd = process.cwd()) {
|
|
15
|
+
if (fs.existsSync(path.join(cwd, 'app.json')))
|
|
16
|
+
return cwd;
|
|
17
|
+
const distPath = path.join(cwd, 'dist');
|
|
18
|
+
if (fs.existsSync(path.join(distPath, 'app.json')))
|
|
19
|
+
return distPath;
|
|
20
|
+
throw createCliError('VALIDATION', 'Could not find app.json in the current directory or ./dist. Run from the app folder or its project root.');
|
|
21
|
+
}
|
|
22
|
+
export function readAppJson(distPath) {
|
|
23
|
+
const appJsonPath = path.join(distPath, 'app.json');
|
|
24
|
+
if (!fs.existsSync(appJsonPath)) {
|
|
25
|
+
throw createCliError('VALIDATION', `app.json not found in ${distPath}`);
|
|
26
|
+
}
|
|
27
|
+
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
|
|
28
|
+
if (!appJson.name) {
|
|
29
|
+
throw createCliError('VALIDATION', 'app.json must contain a "name" field');
|
|
30
|
+
}
|
|
31
|
+
return appJson;
|
|
32
|
+
}
|
|
33
|
+
export async function runDevSync(opts, deps = {}) {
|
|
34
|
+
try {
|
|
35
|
+
const apiKey = resolveApiKey(opts);
|
|
36
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
37
|
+
const distPath = findDistPath(deps.cwd);
|
|
38
|
+
const appJson = readAppJson(distPath);
|
|
39
|
+
const client = deps.client ?? makeClient(baseUrl, apiKey);
|
|
40
|
+
log(`Syncing ${appJson.name} from ${distPath}...`, { quiet: opts.quiet });
|
|
41
|
+
const zip = await zipDirectoryToBuffer(distPath);
|
|
42
|
+
log(`Zip created: ${(zip.length / 1024).toFixed(2)} KB`, { quiet: opts.quiet });
|
|
43
|
+
const form = new FormData();
|
|
44
|
+
form.append('app_name', appJson.name);
|
|
45
|
+
form.append('file_id', opts.fileId);
|
|
46
|
+
form.append('zip', zip, { filename: 'app.zip', contentType: 'application/zip' });
|
|
47
|
+
const res = await client.post(SYNC_ENDPOINT, form, {
|
|
48
|
+
headers: form.getHeaders(),
|
|
49
|
+
maxBodyLength: Infinity,
|
|
50
|
+
maxContentLength: Infinity,
|
|
51
|
+
});
|
|
52
|
+
return ok(res.data);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return fail(toCliError(e));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function runRevert(opts, deps = {}) {
|
|
59
|
+
try {
|
|
60
|
+
const apiKey = resolveApiKey(opts);
|
|
61
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
62
|
+
const client = deps.client ?? makeClient(baseUrl, apiKey);
|
|
63
|
+
const res = await client.delete(SYNC_ENDPOINT, {
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
data: { file_id: opts.fileId },
|
|
66
|
+
});
|
|
67
|
+
return ok({ restored_version: res.data?.restored_version });
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return fail(toCliError(e));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function watchDevSync(opts, debounceMs) {
|
|
74
|
+
const quiet = opts.quiet;
|
|
75
|
+
let distPath;
|
|
76
|
+
let appJson;
|
|
77
|
+
try {
|
|
78
|
+
distPath = findDistPath();
|
|
79
|
+
appJson = readAppJson(distPath);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
emitEnvelope(fail(toCliError(e)), opts);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
log(`Watching ${distPath} for changes (app: ${appJson.name}, debounce ${debounceMs}ms)...`, { quiet });
|
|
86
|
+
let syncTimeout = null;
|
|
87
|
+
let isSyncing = false;
|
|
88
|
+
let isShuttingDown = false;
|
|
89
|
+
const doSync = async () => {
|
|
90
|
+
if (isShuttingDown)
|
|
91
|
+
return;
|
|
92
|
+
if (isSyncing) {
|
|
93
|
+
syncTimeout = setTimeout(doSync, debounceMs);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
isSyncing = true;
|
|
97
|
+
const env = await runDevSync(opts);
|
|
98
|
+
if (env.ok) {
|
|
99
|
+
log(`Synced — ${env.data?.files_uploaded} files`, { quiet });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
log(`Sync failed [${env.error?.code}]: ${env.error?.message}`, { quiet });
|
|
103
|
+
}
|
|
104
|
+
isSyncing = false;
|
|
105
|
+
};
|
|
106
|
+
const watcher = chokidar.watch(distPath, {
|
|
107
|
+
persistent: true,
|
|
108
|
+
ignoreInitial: true,
|
|
109
|
+
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
|
110
|
+
});
|
|
111
|
+
watcher.on('all', (_event, filePath) => {
|
|
112
|
+
if (syncTimeout)
|
|
113
|
+
clearTimeout(syncTimeout);
|
|
114
|
+
log(`Change detected: ${path.relative(distPath, filePath)}`, { quiet });
|
|
115
|
+
syncTimeout = setTimeout(doSync, debounceMs);
|
|
116
|
+
});
|
|
117
|
+
const shutdown = async (signal) => {
|
|
118
|
+
if (isShuttingDown)
|
|
119
|
+
return;
|
|
120
|
+
isShuttingDown = true;
|
|
121
|
+
log(`${signal} received — reverting dev override...`, { quiet });
|
|
122
|
+
await watcher.close();
|
|
123
|
+
if (syncTimeout)
|
|
124
|
+
clearTimeout(syncTimeout);
|
|
125
|
+
while (isSyncing)
|
|
126
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
127
|
+
const env = await runRevert(opts);
|
|
128
|
+
if (!env.ok)
|
|
129
|
+
log(`Failed to revert [${env.error?.code}]: ${env.error?.message}`, { quiet });
|
|
130
|
+
else
|
|
131
|
+
log('Dev override reverted', { quiet });
|
|
132
|
+
process.exit(env.error?.exitCode ?? 0);
|
|
133
|
+
};
|
|
134
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
135
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
136
|
+
log('Initial sync...', { quiet });
|
|
137
|
+
await doSync();
|
|
138
|
+
}
|
|
139
|
+
export function createDevSyncCommand() {
|
|
140
|
+
const program = new Command('dev-sync').description('Sync a marketplace app to S3 for live development testing');
|
|
141
|
+
program
|
|
142
|
+
.command('sync')
|
|
143
|
+
.description('One-time sync of the app to S3')
|
|
144
|
+
.requiredOption('--file-id <id>', 'File ID of the installed app (pitcher_file.id)')
|
|
145
|
+
.option('--api-key <apiKey>', 'API Key for authentication')
|
|
146
|
+
.option('--org <organization>', 'Organization subdomain', 'dev')
|
|
147
|
+
.action(async (options, command) => {
|
|
148
|
+
const globals = command.optsWithGlobals();
|
|
149
|
+
const env = await runDevSync({
|
|
150
|
+
fileId: options.fileId,
|
|
151
|
+
apiKey: options.apiKey,
|
|
152
|
+
org: options.org,
|
|
153
|
+
quiet: globals.quiet,
|
|
154
|
+
});
|
|
155
|
+
emitEnvelope(env, globals);
|
|
156
|
+
});
|
|
157
|
+
program
|
|
158
|
+
.command('watch')
|
|
159
|
+
.description('Watch the dist folder and sync on changes')
|
|
160
|
+
.requiredOption('--file-id <id>', 'File ID of the installed app (pitcher_file.id)')
|
|
161
|
+
.option('--api-key <apiKey>', 'API Key for authentication')
|
|
162
|
+
.option('--org <organization>', 'Organization subdomain', 'dev')
|
|
163
|
+
.option('--debounce <ms>', 'Debounce time in milliseconds', '2500')
|
|
164
|
+
.action(async (options, command) => {
|
|
165
|
+
const globals = command.optsWithGlobals();
|
|
166
|
+
await watchDevSync({
|
|
167
|
+
fileId: options.fileId,
|
|
168
|
+
apiKey: options.apiKey,
|
|
169
|
+
org: options.org,
|
|
170
|
+
quiet: globals.quiet,
|
|
171
|
+
output: globals.output,
|
|
172
|
+
}, parseInt(options.debounce, 10));
|
|
173
|
+
});
|
|
174
|
+
program
|
|
175
|
+
.command('revert')
|
|
176
|
+
.description('Revert the dev override and restore the original version')
|
|
177
|
+
.requiredOption('--file-id <id>', 'File ID of the installed app (pitcher_file.id)')
|
|
178
|
+
.option('--api-key <apiKey>', 'API Key for authentication')
|
|
179
|
+
.option('--org <organization>', 'Organization subdomain', 'dev')
|
|
180
|
+
.action(async (options, command) => {
|
|
181
|
+
const globals = command.optsWithGlobals();
|
|
182
|
+
const env = await runRevert({
|
|
183
|
+
fileId: options.fileId,
|
|
184
|
+
apiKey: options.apiKey,
|
|
185
|
+
org: options.org,
|
|
186
|
+
quiet: globals.quiet,
|
|
187
|
+
});
|
|
188
|
+
emitEnvelope(env, globals);
|
|
189
|
+
});
|
|
190
|
+
return program;
|
|
191
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { AxiosInstance } from 'axios';
|
|
3
|
+
import { type Envelope } from '../core/envelope.js';
|
|
4
|
+
interface PublishResult {
|
|
5
|
+
app?: {
|
|
6
|
+
name?: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
};
|
|
9
|
+
installed?: string[];
|
|
10
|
+
dryRun?: boolean;
|
|
11
|
+
publishUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
interface PublishOptions {
|
|
14
|
+
folder?: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
org?: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
incrementVersion?: boolean;
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
installTo?: string;
|
|
21
|
+
installToEnvName?: string;
|
|
22
|
+
quiet?: boolean;
|
|
23
|
+
}
|
|
24
|
+
interface PublishDeps {
|
|
25
|
+
client?: AxiosInstance;
|
|
26
|
+
tmpDir?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function runPublishApp(opts: PublishOptions, deps?: PublishDeps): Promise<Envelope<PublishResult>>;
|
|
29
|
+
export declare function createPublishAppCommand(): Command;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import FormData from 'form-data';
|
|
7
|
+
import { resolveApiKey } from '../core/auth.js';
|
|
8
|
+
import { resolveBaseUrl } from '../core/baseUrl.js';
|
|
9
|
+
import { makeClient, toCliError } from '../core/http.js';
|
|
10
|
+
import { zipDirectoryToFile } from '../core/archive.js';
|
|
11
|
+
import { ok, fail } from '../core/envelope.js';
|
|
12
|
+
import { createCliError } from '../core/errors.js';
|
|
13
|
+
import { emitEnvelope, log } from '../core/output.js';
|
|
14
|
+
const PUBLISH_PATH = '/api/v1/apps/publish/';
|
|
15
|
+
const INSTANCES_PATH = '/api/v1/instances';
|
|
16
|
+
function buildVersion(now) {
|
|
17
|
+
const year = now.getFullYear();
|
|
18
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
19
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
20
|
+
return `${year}.${month}.${day}.${now.getTime()}`;
|
|
21
|
+
}
|
|
22
|
+
async function fetchAllInstances(client, warnings, quiet) {
|
|
23
|
+
const pageSize = 100;
|
|
24
|
+
const maxPages = 50;
|
|
25
|
+
const first = await client.get(INSTANCES_PATH, { params: { page_size: pageSize, page: 1 } });
|
|
26
|
+
const firstInstances = first.data.results || [];
|
|
27
|
+
if (firstInstances.length === 0)
|
|
28
|
+
return [];
|
|
29
|
+
const totalCount = first.data.count || firstInstances.length;
|
|
30
|
+
const totalPagesRaw = Math.ceil(totalCount / pageSize);
|
|
31
|
+
const totalPages = Math.min(totalPagesRaw, maxPages);
|
|
32
|
+
if (totalPagesRaw > maxPages) {
|
|
33
|
+
warnings.push(`Reached the ${maxPages}-page instance limit (${maxPages * pageSize}); there may be more instances not considered for installation.`);
|
|
34
|
+
}
|
|
35
|
+
if (totalPages === 1)
|
|
36
|
+
return firstInstances;
|
|
37
|
+
const pages = Array.from({ length: totalPages - 1 }, (_, i) => i + 2);
|
|
38
|
+
log(`Fetching ${totalPages - 1} more instance page(s)...`, { quiet });
|
|
39
|
+
const responses = await Promise.all(pages.map((page) => client.get(INSTANCES_PATH, { params: { page_size: pageSize, page } })));
|
|
40
|
+
return [...firstInstances, ...responses.flatMap((r) => r.data.results || [])];
|
|
41
|
+
}
|
|
42
|
+
function selectTargetInstances(all, opts) {
|
|
43
|
+
const byId = new Map(all.map((i) => [i.id, i]));
|
|
44
|
+
const targets = [];
|
|
45
|
+
const warnings = [];
|
|
46
|
+
if (opts.installTo) {
|
|
47
|
+
const requested = opts.installTo.split(',').map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
for (const id of requested) {
|
|
49
|
+
const inst = byId.get(id);
|
|
50
|
+
if (inst)
|
|
51
|
+
targets.push(inst);
|
|
52
|
+
else
|
|
53
|
+
warnings.push(`Unknown instance id skipped: ${id}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (opts.installToEnvName) {
|
|
57
|
+
const env = opts.installToEnvName.toLowerCase();
|
|
58
|
+
const matched = all.filter((i) => i.environment?.name?.toLowerCase() === env);
|
|
59
|
+
if (matched.length === 0)
|
|
60
|
+
warnings.push(`No instances found for environment "${opts.installToEnvName}"`);
|
|
61
|
+
targets.push(...matched);
|
|
62
|
+
}
|
|
63
|
+
const unique = Array.from(new Map(targets.map((i) => [i.id, i])).values());
|
|
64
|
+
return { targets: unique, warnings };
|
|
65
|
+
}
|
|
66
|
+
async function installToInstances(client, appName, instances, quiet) {
|
|
67
|
+
if (instances.length === 0)
|
|
68
|
+
return [];
|
|
69
|
+
const instanceIds = instances.map((i) => i.id);
|
|
70
|
+
log(`Installing "${appName}" to ${instances.length} instance(s)...`, { quiet });
|
|
71
|
+
await client.post(`/api/v1/apps/${appName}/install/`, { instance_ids: instanceIds });
|
|
72
|
+
return instanceIds;
|
|
73
|
+
}
|
|
74
|
+
export async function runPublishApp(opts, deps = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const apiKey = resolveApiKey(opts);
|
|
77
|
+
const baseUrl = resolveBaseUrl({ org: opts.org, url: opts.url });
|
|
78
|
+
const folderPath = path.resolve(opts.folder ?? '.');
|
|
79
|
+
const appJsonPath = path.join(folderPath, 'app.json');
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const existingAppJson = fs.existsSync(appJsonPath)
|
|
82
|
+
? JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'))
|
|
83
|
+
: undefined;
|
|
84
|
+
// --increment-version requires app.json — validate on BOTH dry-run and real
|
|
85
|
+
// paths so dry-run can't report a false success when the file is missing.
|
|
86
|
+
if (opts.incrementVersion && !existingAppJson) {
|
|
87
|
+
return fail(createCliError('VALIDATION', `app.json not found in ${folderPath}`));
|
|
88
|
+
}
|
|
89
|
+
const nextVersion = opts.incrementVersion ? buildVersion(new Date()) : existingAppJson?.version;
|
|
90
|
+
// Preserve original --publish-url semantics: POST to the exact URL given
|
|
91
|
+
// (full path included). Otherwise POST to the canonical publish path
|
|
92
|
+
// relative to the client baseURL (the resolved org origin).
|
|
93
|
+
const publishTarget = opts.url ?? PUBLISH_PATH;
|
|
94
|
+
const displayPublishUrl = opts.url ?? `${baseUrl}${PUBLISH_PATH}`;
|
|
95
|
+
if (opts.dryRun) {
|
|
96
|
+
// Dry run is read-only: report the version that WOULD be written, don't touch disk.
|
|
97
|
+
return ok({
|
|
98
|
+
dryRun: true,
|
|
99
|
+
publishUrl: displayPublishUrl,
|
|
100
|
+
app: existingAppJson ? { name: existingAppJson.name, version: nextVersion } : undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (opts.incrementVersion && existingAppJson) {
|
|
104
|
+
existingAppJson.version = nextVersion;
|
|
105
|
+
fs.writeFileSync(appJsonPath, JSON.stringify(existingAppJson, null, 2));
|
|
106
|
+
log(`Updated app.json version to ${nextVersion}`, { quiet: opts.quiet });
|
|
107
|
+
}
|
|
108
|
+
const client = deps.client ?? makeClient(baseUrl, apiKey);
|
|
109
|
+
const zipPath = path.join(deps.tmpDir ?? os.tmpdir(), `cli-publish-${process.pid}-${randomUUID()}.zip`);
|
|
110
|
+
try {
|
|
111
|
+
log(`Zipping ${folderPath}...`, { quiet: opts.quiet });
|
|
112
|
+
await zipDirectoryToFile(folderPath, zipPath);
|
|
113
|
+
const form = new FormData();
|
|
114
|
+
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
|
+
});
|
|
120
|
+
const app = res.data?.app;
|
|
121
|
+
const result = { app };
|
|
122
|
+
if (!app?.name) {
|
|
123
|
+
warnings.push('Could not determine app name from publish response');
|
|
124
|
+
}
|
|
125
|
+
else if (opts.installTo || opts.installToEnvName) {
|
|
126
|
+
// Best-effort install: the app is already published (non-idempotent),
|
|
127
|
+
// so a post-publish install error must NOT fail the command and lose
|
|
128
|
+
// the publish result — it would invite a double-publish on retry.
|
|
129
|
+
try {
|
|
130
|
+
const all = await fetchAllInstances(client, warnings, opts.quiet);
|
|
131
|
+
if (all.length === 0) {
|
|
132
|
+
warnings.push('No instances available; skipped installation');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const { targets, warnings: selWarnings } = selectTargetInstances(all, opts);
|
|
136
|
+
warnings.push(...selWarnings);
|
|
137
|
+
result.installed = await installToInstances(client, app.name, targets, opts.quiet);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (installError) {
|
|
141
|
+
warnings.push(`Install failed after successful publish: ${toCliError(installError).message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return ok(result, warnings);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
if (fs.existsSync(zipPath))
|
|
148
|
+
fs.unlinkSync(zipPath);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return fail(toCliError(e));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function createPublishAppCommand() {
|
|
156
|
+
return new Command('publish-app')
|
|
157
|
+
.arguments('[folder]')
|
|
158
|
+
.option('--api-key <apiKey>', 'API Key for authentication')
|
|
159
|
+
.option('--org <organization>', 'Organization subdomain', 'dev')
|
|
160
|
+
.option('--increment-version', 'Increment version in app.json')
|
|
161
|
+
.option('--dry-run', 'Perform a dry run without publishing')
|
|
162
|
+
.option('--publish-url <url>', 'Explicit publish URL')
|
|
163
|
+
.option('--install-to <instanceIds>', 'Comma-separated instance IDs to install to after publishing')
|
|
164
|
+
.option('--install-to-env-name <envName>', 'Environment name to install to after publishing')
|
|
165
|
+
.description('zip a folder and publish it as an app to a Pitcher org')
|
|
166
|
+
.action(async (folder, options, command) => {
|
|
167
|
+
const globals = command.optsWithGlobals();
|
|
168
|
+
const env = await runPublishApp({
|
|
169
|
+
folder,
|
|
170
|
+
apiKey: options.apiKey,
|
|
171
|
+
org: options.org,
|
|
172
|
+
url: options.publishUrl,
|
|
173
|
+
incrementVersion: options.incrementVersion,
|
|
174
|
+
dryRun: options.dryRun,
|
|
175
|
+
installTo: options.installTo,
|
|
176
|
+
installToEnvName: options.installToEnvName,
|
|
177
|
+
quiet: globals.quiet,
|
|
178
|
+
});
|
|
179
|
+
emitEnvelope(env, globals);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import archiver from 'archiver';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
export async function zipDirectoryToBuffer(dir) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
7
|
+
archive.on('data', (chunk) => chunks.push(chunk));
|
|
8
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
9
|
+
archive.on('error', reject);
|
|
10
|
+
archive.directory(dir, false);
|
|
11
|
+
archive.finalize();
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export async function zipDirectoryToFile(dir, outPath) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const output = fs.createWriteStream(outPath);
|
|
17
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
18
|
+
output.on('close', () => resolve(archive.pointer()));
|
|
19
|
+
output.on('error', reject);
|
|
20
|
+
archive.on('error', reject);
|
|
21
|
+
archive.pipe(output);
|
|
22
|
+
archive.directory(dir, false);
|
|
23
|
+
archive.finalize();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createCliError } from './errors.js';
|
|
2
|
+
export function resolveApiKey(opts, env = process.env) {
|
|
3
|
+
const flagKey = opts.apiKey?.trim();
|
|
4
|
+
if (flagKey)
|
|
5
|
+
return flagKey;
|
|
6
|
+
const envKey = env.NG_API_KEY?.trim();
|
|
7
|
+
if (envKey)
|
|
8
|
+
return envKey;
|
|
9
|
+
throw createCliError('AUTH', 'API key is required. Provide --api-key or set the NG_API_KEY environment variable.');
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createCliError } from './errors.js';
|
|
2
|
+
export function resolveBaseUrl(opts) {
|
|
3
|
+
if (opts.url) {
|
|
4
|
+
let parsed;
|
|
5
|
+
try {
|
|
6
|
+
parsed = new URL(opts.url);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw createCliError('VALIDATION', `Invalid --publish-url: ${opts.url}`);
|
|
10
|
+
}
|
|
11
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
12
|
+
throw createCliError('VALIDATION', `--publish-url must be an http(s) URL: ${opts.url}`);
|
|
13
|
+
}
|
|
14
|
+
return parsed.origin;
|
|
15
|
+
}
|
|
16
|
+
const org = opts.org || 'dev';
|
|
17
|
+
return `https://${org}.my.pitcher.com`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CliError, ErrorCodeName } from './errors.js';
|
|
2
|
+
export interface Envelope<T> {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
data?: T;
|
|
5
|
+
error?: {
|
|
6
|
+
code: ErrorCodeName;
|
|
7
|
+
exitCode: number;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
warnings?: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function ok<T>(data: T, warnings?: string[]): Envelope<T>;
|
|
13
|
+
export declare function fail(error: CliError, warnings?: string[]): Envelope<never>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function ok(data, warnings) {
|
|
2
|
+
return { ok: true, data, ...(warnings?.length ? { warnings } : {}) };
|
|
3
|
+
}
|
|
4
|
+
export function fail(error, warnings) {
|
|
5
|
+
return {
|
|
6
|
+
ok: false,
|
|
7
|
+
error: { code: error.code, exitCode: error.exitCode, message: error.message },
|
|
8
|
+
...(warnings?.length ? { warnings } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const ERROR_CODES: {
|
|
2
|
+
readonly OK: 0;
|
|
3
|
+
readonly UNKNOWN: 1;
|
|
4
|
+
readonly AUTH: 2;
|
|
5
|
+
readonly VALIDATION: 3;
|
|
6
|
+
readonly NETWORK: 4;
|
|
7
|
+
readonly NOT_FOUND: 5;
|
|
8
|
+
readonly CONFLICT: 6;
|
|
9
|
+
};
|
|
10
|
+
export type ErrorCodeName = keyof typeof ERROR_CODES;
|
|
11
|
+
export interface CliError {
|
|
12
|
+
__cliError: true;
|
|
13
|
+
code: ErrorCodeName;
|
|
14
|
+
exitCode: number;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function createCliError(code: ErrorCodeName, message: string): CliError;
|
|
18
|
+
export declare function isCliError(e: unknown): e is CliError;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const ERROR_CODES = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
UNKNOWN: 1,
|
|
4
|
+
AUTH: 2,
|
|
5
|
+
VALIDATION: 3,
|
|
6
|
+
NETWORK: 4,
|
|
7
|
+
NOT_FOUND: 5,
|
|
8
|
+
CONFLICT: 6,
|
|
9
|
+
};
|
|
10
|
+
export function createCliError(code, message) {
|
|
11
|
+
return { __cliError: true, code, exitCode: ERROR_CODES[code], message };
|
|
12
|
+
}
|
|
13
|
+
export function isCliError(e) {
|
|
14
|
+
if (typeof e !== 'object' || e === null)
|
|
15
|
+
return false;
|
|
16
|
+
const c = e;
|
|
17
|
+
if (c.__cliError !== true || typeof c.code !== 'string' || typeof c.message !== 'string') {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
// Own key only (reject inherited names like 'toString') and the exit code
|
|
21
|
+
// must match the canonical value for that code.
|
|
22
|
+
if (!Object.prototype.hasOwnProperty.call(ERROR_CODES, c.code))
|
|
23
|
+
return false;
|
|
24
|
+
return c.exitCode === ERROR_CODES[c.code];
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createCliError, isCliError } from './errors.js';
|
|
3
|
+
export function makeClient(baseUrl, apiKey) {
|
|
4
|
+
return axios.create({
|
|
5
|
+
baseURL: baseUrl,
|
|
6
|
+
headers: {
|
|
7
|
+
accept: 'application/json',
|
|
8
|
+
'x-api-key': apiKey,
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function toCliError(e) {
|
|
13
|
+
if (isCliError(e))
|
|
14
|
+
return e;
|
|
15
|
+
const err = (e ?? {});
|
|
16
|
+
const data = err.response?.data;
|
|
17
|
+
const apiMessage = data?.detail || data?.error || err.message || 'Request failed';
|
|
18
|
+
const status = err.response?.status;
|
|
19
|
+
if (typeof status === 'number') {
|
|
20
|
+
if (status === 401 || status === 403)
|
|
21
|
+
return createCliError('AUTH', apiMessage);
|
|
22
|
+
if (status === 404)
|
|
23
|
+
return createCliError('NOT_FOUND', apiMessage);
|
|
24
|
+
if (status === 409)
|
|
25
|
+
return createCliError('CONFLICT', apiMessage);
|
|
26
|
+
if (status >= 400 && status < 500)
|
|
27
|
+
return createCliError('VALIDATION', apiMessage);
|
|
28
|
+
return createCliError('NETWORK', apiMessage);
|
|
29
|
+
}
|
|
30
|
+
if (err.isAxiosError)
|
|
31
|
+
return createCliError('NETWORK', apiMessage);
|
|
32
|
+
return createCliError('UNKNOWN', apiMessage);
|
|
33
|
+
}
|