@relesio/cli 0.2.6
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/README.md +224 -0
- package/bin/relesio.js +4 -0
- package/dist/commands/auth/index.d.ts +2 -0
- package/dist/commands/auth/index.js +9 -0
- package/dist/commands/auth/login.d.ts +2 -0
- package/dist/commands/auth/login.js +82 -0
- package/dist/commands/auth/logout.d.ts +2 -0
- package/dist/commands/auth/logout.js +25 -0
- package/dist/commands/auth/status.d.ts +2 -0
- package/dist/commands/auth/status.js +35 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +293 -0
- package/dist/commands/organizations/index.d.ts +2 -0
- package/dist/commands/organizations/index.js +8 -0
- package/dist/commands/organizations/list.d.ts +2 -0
- package/dist/commands/organizations/list.js +54 -0
- package/dist/commands/organizations/set.d.ts +2 -0
- package/dist/commands/organizations/set.js +34 -0
- package/dist/commands/projects/index.d.ts +2 -0
- package/dist/commands/projects/index.js +6 -0
- package/dist/commands/projects/list.d.ts +2 -0
- package/dist/commands/projects/list.js +49 -0
- package/dist/commands/rollback.d.ts +2 -0
- package/dist/commands/rollback.js +119 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +59 -0
- package/dist/commands/team/get.d.ts +2 -0
- package/dist/commands/team/get.js +34 -0
- package/dist/commands/team/index.d.ts +2 -0
- package/dist/commands/team/index.js +7 -0
- package/dist/commands/team/list.d.ts +2 -0
- package/dist/commands/team/list.js +46 -0
- package/dist/commands/upload.d.ts +2 -0
- package/dist/commands/upload.js +365 -0
- package/dist/commands/versions/index.d.ts +2 -0
- package/dist/commands/versions/index.js +6 -0
- package/dist/commands/versions/list.d.ts +2 -0
- package/dist/commands/versions/list.js +51 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +52 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +90 -0
- package/dist/lib/api/client.d.ts +32 -0
- package/dist/lib/api/client.js +187 -0
- package/dist/lib/api/deployments.d.ts +122 -0
- package/dist/lib/api/deployments.js +55 -0
- package/dist/lib/api/organizations.d.ts +27 -0
- package/dist/lib/api/organizations.js +37 -0
- package/dist/lib/api/projects.d.ts +38 -0
- package/dist/lib/api/projects.js +34 -0
- package/dist/lib/api/teams.d.ts +15 -0
- package/dist/lib/api/teams.js +44 -0
- package/dist/lib/api/types.d.ts +89 -0
- package/dist/lib/api/types.js +2 -0
- package/dist/lib/api/versions.d.ts +140 -0
- package/dist/lib/api/versions.js +53 -0
- package/dist/lib/config/manager.d.ts +35 -0
- package/dist/lib/config/manager.js +78 -0
- package/dist/lib/config/types.d.ts +15 -0
- package/dist/lib/config/types.js +15 -0
- package/dist/lib/errors/handler.d.ts +1 -0
- package/dist/lib/errors/handler.js +75 -0
- package/dist/lib/errors/types.d.ts +19 -0
- package/dist/lib/errors/types.js +34 -0
- package/dist/lib/files/scanner.d.ts +23 -0
- package/dist/lib/files/scanner.js +81 -0
- package/dist/lib/git/metadata.d.ts +31 -0
- package/dist/lib/git/metadata.js +90 -0
- package/dist/lib/output/colors.d.ts +14 -0
- package/dist/lib/output/colors.js +23 -0
- package/dist/lib/output/formatter.d.ts +2 -0
- package/dist/lib/output/formatter.js +7 -0
- package/dist/lib/output/table.d.ts +7 -0
- package/dist/lib/output/table.js +64 -0
- package/package.json +58 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { ConfigManager } from "../../lib/config/manager.js";
|
|
4
|
+
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
5
|
+
import { TeamsAPI } from "../../lib/api/teams.js";
|
|
6
|
+
import { renderTeamDetails } from "../../lib/output/table.js";
|
|
7
|
+
import { outputJSON, shouldOutputJSON } from "../../lib/output/formatter.js";
|
|
8
|
+
import { AuthenticationError } from "../../lib/errors/types.js";
|
|
9
|
+
export const getCommand = new Command("get")
|
|
10
|
+
.description("Get details of a specific team")
|
|
11
|
+
.argument("<teamId>", "Team ID")
|
|
12
|
+
.action(async (teamId) => {
|
|
13
|
+
const config = ConfigManager.load();
|
|
14
|
+
if (!config.apiToken) {
|
|
15
|
+
throw new AuthenticationError();
|
|
16
|
+
}
|
|
17
|
+
const spinner = ora("Fetching team details...").start();
|
|
18
|
+
try {
|
|
19
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
20
|
+
const teamsAPI = new TeamsAPI(client);
|
|
21
|
+
const team = await teamsAPI.get(teamId);
|
|
22
|
+
spinner.stop();
|
|
23
|
+
if (shouldOutputJSON()) {
|
|
24
|
+
outputJSON(team);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
renderTeamDetails(team);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
spinner.fail("Failed to fetch team details");
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { ConfigManager } from "../../lib/config/manager.js";
|
|
4
|
+
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
5
|
+
import { TeamsAPI } from "../../lib/api/teams.js";
|
|
6
|
+
import { renderTeamsTable } from "../../lib/output/table.js";
|
|
7
|
+
import { outputJSON, shouldOutputJSON } from "../../lib/output/formatter.js";
|
|
8
|
+
import { AuthenticationError } from "../../lib/errors/types.js";
|
|
9
|
+
export const listCommand = new Command("list")
|
|
10
|
+
.alias("ls")
|
|
11
|
+
.description("List all teams")
|
|
12
|
+
.option("--limit <limit>", "Number of teams to return (default: 50)", "50")
|
|
13
|
+
.option("--offset <offset>", "Offset for pagination (default: 0)", "0")
|
|
14
|
+
.action(async (options, command) => {
|
|
15
|
+
const config = ConfigManager.load();
|
|
16
|
+
if (!config.apiToken) {
|
|
17
|
+
throw new AuthenticationError();
|
|
18
|
+
}
|
|
19
|
+
// Get org context from --org flag or config
|
|
20
|
+
const orgId = command.parent?.opts().org || config.activeOrganizationId;
|
|
21
|
+
if (!orgId) {
|
|
22
|
+
const spinner = ora();
|
|
23
|
+
spinner.fail("No organization context. Use --org flag, set RELESIO_ORG_ID, or run 'relesio organizations set <org-id>'");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const spinner = ora("Fetching teams...").start();
|
|
27
|
+
try {
|
|
28
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
29
|
+
const teamsAPI = new TeamsAPI(client);
|
|
30
|
+
const response = await teamsAPI.list({
|
|
31
|
+
limit: parseInt(options.limit, 10),
|
|
32
|
+
offset: parseInt(options.offset, 10)
|
|
33
|
+
});
|
|
34
|
+
spinner.stop();
|
|
35
|
+
if (shouldOutputJSON()) {
|
|
36
|
+
outputJSON(response);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
renderTeamsTable(response.data);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
spinner.fail("Failed to fetch teams");
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { RelesioAPIClient } from "../lib/api/client.js";
|
|
7
|
+
import { ConfigManager } from "../lib/config/manager.js";
|
|
8
|
+
import { getProjectBySlug, createProject } from "../lib/api/projects.js";
|
|
9
|
+
import { createVersion, completeVersion, gitMetadataToApiFormat } from "../lib/api/versions.js";
|
|
10
|
+
import { collectGitMetadata, formatGitMetadata, validateGitState } from "../lib/git/metadata.js";
|
|
11
|
+
import { scanDirectory, formatFileSize, getTotalSize } from "../lib/files/scanner.js";
|
|
12
|
+
import { handleError } from "../lib/errors/handler.js";
|
|
13
|
+
const PACKAGE_JSON = "package.json";
|
|
14
|
+
const validateSlug = (input) => {
|
|
15
|
+
if (!input || input.trim().length === 0) {
|
|
16
|
+
return "Project slug is required";
|
|
17
|
+
}
|
|
18
|
+
if (!/^[a-z0-9-]+$/.test(input)) {
|
|
19
|
+
return "Project slug must contain only lowercase letters, numbers, and hyphens";
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
function slugify(value) {
|
|
24
|
+
if (!value)
|
|
25
|
+
return undefined;
|
|
26
|
+
const slug = value
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "");
|
|
31
|
+
return slug || undefined;
|
|
32
|
+
}
|
|
33
|
+
function loadPackageMetadata(baseDir) {
|
|
34
|
+
const candidates = new Set([
|
|
35
|
+
path.resolve(process.cwd(), PACKAGE_JSON),
|
|
36
|
+
path.resolve(baseDir, PACKAGE_JSON),
|
|
37
|
+
path.resolve(path.dirname(baseDir), PACKAGE_JSON)
|
|
38
|
+
]);
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
if (!fs.existsSync(candidate))
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
const content = fs.readFileSync(candidate, "utf8");
|
|
44
|
+
const parsed = JSON.parse(content);
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Ignore parse errors and keep searching
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
function buildDependencies(pkg) {
|
|
54
|
+
if (!pkg)
|
|
55
|
+
return undefined;
|
|
56
|
+
const deps = pkg.dependencies ?? {};
|
|
57
|
+
const devDeps = pkg.devDependencies ?? {};
|
|
58
|
+
const toList = (obj, type) => Object.entries(obj).map(([name, version]) => ({ name, version, type }));
|
|
59
|
+
const combined = [
|
|
60
|
+
...toList(deps, "dependency"),
|
|
61
|
+
...toList(devDeps, "devDependency")
|
|
62
|
+
];
|
|
63
|
+
return combined.length > 0 ? combined : undefined;
|
|
64
|
+
}
|
|
65
|
+
export const uploadCommand = new Command("upload")
|
|
66
|
+
.description("Upload a new version to a project")
|
|
67
|
+
.argument("[directory]", "Directory containing files to upload (default: dist)", "dist")
|
|
68
|
+
.option("-p, --project <slug>", "Project slug")
|
|
69
|
+
.option("-v, --version <version>", "Version string (e.g., 1.0.0)")
|
|
70
|
+
.option("--require-clean", "Require clean git working directory")
|
|
71
|
+
.option("-y, --yes", "Skip confirmation prompts")
|
|
72
|
+
.option("--json", "Output as JSON")
|
|
73
|
+
.action(async (directory, options) => {
|
|
74
|
+
const spinner = ora("Preparing upload...").start();
|
|
75
|
+
try {
|
|
76
|
+
const config = ConfigManager.load();
|
|
77
|
+
if (!config.apiToken) {
|
|
78
|
+
spinner.fail("Not authenticated. Run 'relesio auth login' first.");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
82
|
+
const resolvedDirectory = path.resolve(directory || "dist");
|
|
83
|
+
const packageMetadata = loadPackageMetadata(resolvedDirectory);
|
|
84
|
+
const inferredSlug = slugify(packageMetadata.name);
|
|
85
|
+
// Step 1: Collect git metadata
|
|
86
|
+
spinner.text = "Collecting git metadata...";
|
|
87
|
+
const gitMetadata = await collectGitMetadata();
|
|
88
|
+
try {
|
|
89
|
+
validateGitState(gitMetadata, options.requireClean);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
spinner.fail(error instanceof Error ? error.message : String(error));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
// Step 2: Scan directory for files
|
|
96
|
+
spinner.text = "Scanning directory...";
|
|
97
|
+
let files;
|
|
98
|
+
try {
|
|
99
|
+
files = await scanDirectory(resolvedDirectory);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
spinner.fail(`Failed to scan directory '${resolvedDirectory}': ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
if (files.length === 0) {
|
|
106
|
+
spinner.fail("No files found in directory");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const totalSize = getTotalSize(files);
|
|
110
|
+
spinner.stop();
|
|
111
|
+
// Step 3: Get or prompt for project
|
|
112
|
+
let projectSlug = options.project || "";
|
|
113
|
+
if (!projectSlug &&
|
|
114
|
+
inferredSlug &&
|
|
115
|
+
validateSlug(inferredSlug) === true) {
|
|
116
|
+
projectSlug = inferredSlug;
|
|
117
|
+
console.log(chalk.dim(`Using project slug from package.json: ${chalk.cyan(projectSlug)}`));
|
|
118
|
+
}
|
|
119
|
+
if (!projectSlug) {
|
|
120
|
+
projectSlug =
|
|
121
|
+
slugify(path.basename(resolvedDirectory)) ||
|
|
122
|
+
slugify(path.basename(process.cwd())) ||
|
|
123
|
+
"project";
|
|
124
|
+
console.log(chalk.dim(`Using inferred project slug: ${chalk.cyan(projectSlug)}`));
|
|
125
|
+
}
|
|
126
|
+
const validationResult = validateSlug(projectSlug);
|
|
127
|
+
if (validationResult !== true) {
|
|
128
|
+
spinner.fail(String(validationResult));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
// Get or create project
|
|
132
|
+
spinner.start("Loading project...");
|
|
133
|
+
let project = await getProjectBySlug(client, projectSlug);
|
|
134
|
+
if (!project) {
|
|
135
|
+
spinner.stop();
|
|
136
|
+
spinner.start("Project not found. Creating...");
|
|
137
|
+
const createResult = await createProject(client, {
|
|
138
|
+
name: projectSlug,
|
|
139
|
+
slug: projectSlug
|
|
140
|
+
});
|
|
141
|
+
project = createResult.project;
|
|
142
|
+
spinner.succeed(`Created project '${project.name}'`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
spinner.stop();
|
|
146
|
+
}
|
|
147
|
+
// Step 4: Determine version (non-interactive)
|
|
148
|
+
const semverPattern = /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/;
|
|
149
|
+
let versionString = options.version ||
|
|
150
|
+
packageMetadata.version ||
|
|
151
|
+
gitMetadata.tag ||
|
|
152
|
+
"0.0.1";
|
|
153
|
+
const semanticVersion = packageMetadata.version;
|
|
154
|
+
if (!semverPattern.test(versionString)) {
|
|
155
|
+
console.log(chalk.yellow(`Version '${versionString}' is not valid semver. Falling back to 0.0.1.`));
|
|
156
|
+
versionString = "0.0.1";
|
|
157
|
+
}
|
|
158
|
+
if (!options.version && !packageMetadata.version) {
|
|
159
|
+
console.log(chalk.dim(`No package.json version found. Using ${chalk.cyan(versionString)}`));
|
|
160
|
+
}
|
|
161
|
+
// Step 5: Display upload summary (auto-confirm)
|
|
162
|
+
console.log(`\n${chalk.bold("Upload Summary:")}\n` +
|
|
163
|
+
` Project: ${chalk.cyan(project.name)} (${project.slug})\n` +
|
|
164
|
+
` Version: ${chalk.cyan(versionString)}\n` +
|
|
165
|
+
` Files: ${chalk.cyan(files.length.toString())}\n` +
|
|
166
|
+
` Total Size: ${chalk.cyan(formatFileSize(totalSize))}\n`);
|
|
167
|
+
if (gitMetadata.sha) {
|
|
168
|
+
console.log(chalk.bold("\nGit Metadata:"));
|
|
169
|
+
console.log(formatGitMetadata(gitMetadata));
|
|
170
|
+
}
|
|
171
|
+
console.log(chalk.dim("\nAuto-confirming upload (non-interactive). Proceeding...\n"));
|
|
172
|
+
// Step 6: Create version and get presigned URLs
|
|
173
|
+
spinner.start("Initiating upload...");
|
|
174
|
+
const dependencyList = buildDependencies(packageMetadata);
|
|
175
|
+
const metadata = {};
|
|
176
|
+
if (packageMetadata.name)
|
|
177
|
+
metadata.packageName = packageMetadata.name;
|
|
178
|
+
if (packageMetadata.version)
|
|
179
|
+
metadata.packageVersion = packageMetadata.version;
|
|
180
|
+
if (packageMetadata.dependencies)
|
|
181
|
+
metadata.packageDependencies = packageMetadata.dependencies;
|
|
182
|
+
if (packageMetadata.devDependencies)
|
|
183
|
+
metadata.packageDevDependencies =
|
|
184
|
+
packageMetadata.devDependencies;
|
|
185
|
+
if (dependencyList)
|
|
186
|
+
metadata.dependencies = dependencyList;
|
|
187
|
+
if (gitMetadata.branch)
|
|
188
|
+
metadata.gitBranch = gitMetadata.branch;
|
|
189
|
+
if (gitMetadata.remoteUrl)
|
|
190
|
+
metadata.gitRemote = gitMetadata.remoteUrl;
|
|
191
|
+
metadata.gitDirty = gitMetadata.isDirty;
|
|
192
|
+
const createVersionResponse = await createVersion(client, project.id, {
|
|
193
|
+
version: versionString,
|
|
194
|
+
packageName: packageMetadata.name,
|
|
195
|
+
semanticVersion,
|
|
196
|
+
fileCount: files.length,
|
|
197
|
+
totalSizeBytes: totalSize,
|
|
198
|
+
files: files.map((f) => ({
|
|
199
|
+
path: f.path,
|
|
200
|
+
contentType: f.contentType,
|
|
201
|
+
size: f.size,
|
|
202
|
+
checksum: f.checksum
|
|
203
|
+
})),
|
|
204
|
+
dependencies: dependencyList,
|
|
205
|
+
metadata: Object.keys(metadata).length > 0
|
|
206
|
+
? Object.fromEntries(Object.entries(metadata).filter(([, value]) => value !== undefined))
|
|
207
|
+
: undefined,
|
|
208
|
+
...gitMetadataToApiFormat(gitMetadata)
|
|
209
|
+
});
|
|
210
|
+
spinner.succeed(`Version ${versionString} created (${createVersionResponse.versionId})`);
|
|
211
|
+
// Handle duplicate version (content-addressed deduplication)
|
|
212
|
+
if (createVersionResponse.stats?.alreadyExists) {
|
|
213
|
+
console.log(chalk.green("\n✓ Version already exists (identical content)"));
|
|
214
|
+
console.log(chalk.dim(` Files: ${createVersionResponse.stats.existingFileCount}`));
|
|
215
|
+
console.log(chalk.dim(` Preview URL: ${chalk.cyan(createVersionResponse.previewUrl)}`));
|
|
216
|
+
console.log(chalk.dim("\nSkipping upload (no changes needed)\n"));
|
|
217
|
+
return; // Exit early - version complete
|
|
218
|
+
}
|
|
219
|
+
// Display copy stats if incremental upload used (Phase 2)
|
|
220
|
+
if (createVersionResponse.stats &&
|
|
221
|
+
createVersionResponse.stats.copied > 0) {
|
|
222
|
+
console.log(chalk.dim(` Server copied: ${createVersionResponse.stats.copied} unchanged files ` +
|
|
223
|
+
`(saved ${formatFileSize(createVersionResponse.stats.savedBytes)})`));
|
|
224
|
+
}
|
|
225
|
+
// Step 7: Upload files to R2 via presigned URLs (parallel batching)
|
|
226
|
+
const uploadSpinner = ora("Uploading files...").start();
|
|
227
|
+
const CONCURRENT_UPLOADS = 10; // Parallel upload limit
|
|
228
|
+
const uploadUrls = createVersionResponse.uploadUrls;
|
|
229
|
+
if (uploadUrls.length === 0) {
|
|
230
|
+
uploadSpinner.info("No files to upload (all copied server-side)");
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
let uploadedCount = 0;
|
|
234
|
+
// Upload in batches for better performance (prevents 180s timeout)
|
|
235
|
+
for (let i = 0; i < uploadUrls.length; i += CONCURRENT_UPLOADS) {
|
|
236
|
+
const batch = uploadUrls.slice(i, i + CONCURRENT_UPLOADS);
|
|
237
|
+
const uploadPromises = batch.map(async (uploadUrl) => {
|
|
238
|
+
// Find corresponding file
|
|
239
|
+
const file = files.find((f) => f.path === uploadUrl.filePath);
|
|
240
|
+
if (!file) {
|
|
241
|
+
throw new Error(`File not found in manifest: ${uploadUrl.filePath}`);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
// Read file
|
|
245
|
+
const fileBuffer = fs.readFileSync(file.absolutePath);
|
|
246
|
+
// Convert hex checksum to base64 for AWS ChecksumSHA256 header
|
|
247
|
+
const _checksumBase64 = Buffer.from(file.checksum, "hex").toString("base64");
|
|
248
|
+
// Upload file (either to presigned R2 URL or authenticated API endpoint)
|
|
249
|
+
// NOTE: If using presigned URLs, cannot send x-amz-meta-* headers (signature mismatch)
|
|
250
|
+
// Metadata will be set by worker binding after upload confirmation
|
|
251
|
+
const headers = {
|
|
252
|
+
"Content-Type": file.contentType
|
|
253
|
+
// Note: Content-Length is auto-added by fetch
|
|
254
|
+
};
|
|
255
|
+
// Add auth header if uploading to API endpoint (not presigned R2 URL)
|
|
256
|
+
if (!uploadUrl.uploadUrl.includes("r2.cloudflarestorage.com")) {
|
|
257
|
+
headers.Authorization = `Bearer ${config.apiToken}`;
|
|
258
|
+
}
|
|
259
|
+
const response = await fetch(uploadUrl.uploadUrl, {
|
|
260
|
+
method: "PUT",
|
|
261
|
+
body: fileBuffer,
|
|
262
|
+
headers
|
|
263
|
+
});
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
// Get response body for detailed error info
|
|
266
|
+
const responseText = await response.text();
|
|
267
|
+
if (process.env.DEBUG) {
|
|
268
|
+
console.error(`\n[DEBUG] Upload failed for ${file.path}`);
|
|
269
|
+
console.error(` Status: ${response.status} ${response.statusText}`);
|
|
270
|
+
console.error(` Response: ${responseText}`);
|
|
271
|
+
console.error(` Headers sent: ${JSON.stringify({
|
|
272
|
+
"Content-Type": file.contentType,
|
|
273
|
+
"Content-Length": file.size.toString(),
|
|
274
|
+
"x-amz-checksum-sha256": Buffer.from(file.checksum, "hex").toString("base64")
|
|
275
|
+
}, null, 2)}`);
|
|
276
|
+
}
|
|
277
|
+
// Map R2/presigned URL errors to user-friendly messages
|
|
278
|
+
const errorMessages = {
|
|
279
|
+
403: "Upload expired (presigned URL timeout after 180s)",
|
|
280
|
+
400: "Checksum mismatch (file may be corrupted)",
|
|
281
|
+
413: "File too large for upload"
|
|
282
|
+
};
|
|
283
|
+
const errorMsg = errorMessages[response.status] ||
|
|
284
|
+
`${response.status} ${response.statusText}`;
|
|
285
|
+
throw new Error(`Upload failed for ${file.path}: ${errorMsg}${responseText ? ` - ${responseText}` : ""}`);
|
|
286
|
+
}
|
|
287
|
+
return uploadUrl.filePath;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
throw new Error(`Failed to upload ${file.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
// Wait for batch to complete (fail fast on any error)
|
|
294
|
+
const results = await Promise.allSettled(uploadPromises);
|
|
295
|
+
const failed = results.find((r) => r.status === "rejected");
|
|
296
|
+
if (failed && failed.status === "rejected") {
|
|
297
|
+
uploadSpinner.fail("Upload failed");
|
|
298
|
+
throw failed.reason;
|
|
299
|
+
}
|
|
300
|
+
uploadedCount += batch.length;
|
|
301
|
+
uploadSpinner.text = `Uploading (${uploadedCount}/${uploadUrls.length})`;
|
|
302
|
+
}
|
|
303
|
+
// Display final upload summary with copy stats (Phase 2)
|
|
304
|
+
const totalFiles = files.length;
|
|
305
|
+
const copied = createVersionResponse.copiedFiles?.length || 0;
|
|
306
|
+
if (copied > 0) {
|
|
307
|
+
uploadSpinner.succeed(`Uploaded ${totalFiles} files (${uploadedCount} uploaded, ${copied} copied server-side)`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
uploadSpinner.succeed(`Uploaded ${uploadedCount} files`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Step 8: Complete the version
|
|
314
|
+
const completeSpinner = ora("Finalizing version...").start();
|
|
315
|
+
const completeResponse = await completeVersion(client, project.id, createVersionResponse.versionId, {
|
|
316
|
+
assets: createVersionResponse.uploadUrls.map((u) => {
|
|
317
|
+
const file = files.find((f) => f.path === u.filePath);
|
|
318
|
+
if (!file)
|
|
319
|
+
throw new Error(`File not found for path: ${u.filePath}`);
|
|
320
|
+
return {
|
|
321
|
+
filePath: file.path,
|
|
322
|
+
sizeBytes: file.size,
|
|
323
|
+
contentType: file.contentType,
|
|
324
|
+
r2Key: u.r2Key,
|
|
325
|
+
checksum: file.checksum
|
|
326
|
+
};
|
|
327
|
+
}),
|
|
328
|
+
// Include copied files list for server validation (Phase 2)
|
|
329
|
+
copiedAssets: createVersionResponse.copiedFiles
|
|
330
|
+
});
|
|
331
|
+
completeSpinner.succeed(`Version ${chalk.green(versionString)} upload complete!`);
|
|
332
|
+
// Step 9: Display results
|
|
333
|
+
if (options.json) {
|
|
334
|
+
console.log(JSON.stringify({
|
|
335
|
+
project: {
|
|
336
|
+
id: project.id,
|
|
337
|
+
name: project.name,
|
|
338
|
+
slug: project.slug
|
|
339
|
+
},
|
|
340
|
+
version: {
|
|
341
|
+
id: completeResponse.versionId,
|
|
342
|
+
version: versionString,
|
|
343
|
+
fileCount: completeResponse.fileCount,
|
|
344
|
+
totalSize: completeResponse.totalSize
|
|
345
|
+
},
|
|
346
|
+
previewUrl: completeResponse.previewUrl,
|
|
347
|
+
gitMetadata
|
|
348
|
+
}, null, 2));
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
console.log(`\n${chalk.bold("Upload Successful!")}\n` +
|
|
352
|
+
` Version: ${chalk.green(versionString)}\n` +
|
|
353
|
+
` Files: ${chalk.green(completeResponse.fileCount.toString())}\n` +
|
|
354
|
+
` Total Size: ${chalk.green(formatFileSize(completeResponse.totalSize))}\n` +
|
|
355
|
+
` Preview URL: ${chalk.cyan(completeResponse.previewUrl)}\n`);
|
|
356
|
+
console.log(chalk.dim(`\nNext steps:\n` +
|
|
357
|
+
` 1. Test your version at the preview URL\n` +
|
|
358
|
+
` 2. Deploy to an environment: ${chalk.cyan(`relesio deploy ${project.slug} ${versionString} --env staging`)}\n`));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
spinner.fail("Upload failed");
|
|
363
|
+
handleError(error);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
4
|
+
import { ConfigManager } from "../../lib/config/manager.js";
|
|
5
|
+
import { getProjectBySlug } from "../../lib/api/projects.js";
|
|
6
|
+
import { listVersions } from "../../lib/api/versions.js";
|
|
7
|
+
import { formatTable } from "../../lib/output/table.js";
|
|
8
|
+
import { handleError } from "../../lib/errors/handler.js";
|
|
9
|
+
export const listVersionsCommand = new Command("list")
|
|
10
|
+
.description("List versions for a project")
|
|
11
|
+
.argument("<project-slug>", "Project slug")
|
|
12
|
+
.option("--json", "Output as JSON")
|
|
13
|
+
.action(async (projectSlug, options) => {
|
|
14
|
+
const spinner = ora("Loading versions...").start();
|
|
15
|
+
try {
|
|
16
|
+
const config = ConfigManager.load();
|
|
17
|
+
if (!config.apiToken) {
|
|
18
|
+
spinner.fail("Not authenticated. Run 'relesio auth login' first.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
22
|
+
// Get project by slug
|
|
23
|
+
const project = await getProjectBySlug(client, projectSlug);
|
|
24
|
+
if (!project) {
|
|
25
|
+
spinner.fail(`Project '${projectSlug}' not found`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const { versions, count } = await listVersions(client, project.id);
|
|
29
|
+
spinner.stop();
|
|
30
|
+
if (options.json) {
|
|
31
|
+
console.log(JSON.stringify({ versions, count }, null, 2));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (versions.length === 0) {
|
|
35
|
+
console.log(`\n✨ No versions yet for ${project.name}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`\n📦 Versions for ${project.name} (${count}):\n`);
|
|
39
|
+
console.log(formatTable(["Version", "Status", "Git SHA", "Branch", "Uploaded"], versions.map((v) => [
|
|
40
|
+
v.semanticVersion || v.version || v.id,
|
|
41
|
+
v.status === "completed" ? "✓" : v.status,
|
|
42
|
+
v.gitSha?.substring(0, 7) || "-",
|
|
43
|
+
v.gitBranch || "-",
|
|
44
|
+
new Date(v.uploadDate).toLocaleString()
|
|
45
|
+
])));
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
spinner.fail("Failed to list versions");
|
|
49
|
+
handleError(error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ConfigManager } from "../lib/config/manager.js";
|
|
3
|
+
import { RelesioAPIClient } from "../lib/api/client.js";
|
|
4
|
+
import { OrganizationsAPI } from "../lib/api/organizations.js";
|
|
5
|
+
import { colors, formatWarning } from "../lib/output/colors.js";
|
|
6
|
+
import { outputJSON, shouldOutputJSON } from "../lib/output/formatter.js";
|
|
7
|
+
export const whoamiCommand = new Command("whoami")
|
|
8
|
+
.description("Display current authentication status")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
const config = ConfigManager.load();
|
|
11
|
+
if (!config.apiToken) {
|
|
12
|
+
if (shouldOutputJSON()) {
|
|
13
|
+
outputJSON({ authenticated: false });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
console.log(formatWarning("Not authenticated"));
|
|
17
|
+
console.log(colors.dim('Run "relesio auth login" to authenticate'));
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (shouldOutputJSON()) {
|
|
22
|
+
// For JSON output, just show the config
|
|
23
|
+
outputJSON({
|
|
24
|
+
authenticated: true,
|
|
25
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
26
|
+
currentOrgId: config.currentOrgId,
|
|
27
|
+
currentOrgName: config.currentOrgName
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// For table output, fetch additional info from API
|
|
32
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
33
|
+
const orgsAPI = new OrganizationsAPI(client);
|
|
34
|
+
try {
|
|
35
|
+
const response = await orgsAPI.getUserOrgs();
|
|
36
|
+
console.log(colors.bold("\n Authentication Status"));
|
|
37
|
+
console.log(colors.dim("─".repeat(50)));
|
|
38
|
+
console.log(`Token: ${colors.dim(config.apiToken.substring(0, 20))}...`);
|
|
39
|
+
console.log(`Current Org: ${colors.highlight(config.currentOrgName || "None")} ${config.currentOrgId ? colors.dim(`(${config.currentOrgId})`) : ""}`);
|
|
40
|
+
console.log(`Organizations: ${response.organizations.length}`);
|
|
41
|
+
console.log(`API Base URL: ${colors.dim(config.apiBaseUrl)}`);
|
|
42
|
+
}
|
|
43
|
+
catch (_error) {
|
|
44
|
+
// If API call fails, show what we have in config
|
|
45
|
+
console.log(colors.bold("\nAuthentication Status"));
|
|
46
|
+
console.log(colors.dim("─".repeat(50)));
|
|
47
|
+
console.log(`Token: ${colors.dim(config.apiToken.substring(0, 20))}...`);
|
|
48
|
+
console.log(`Current Org: ${colors.highlight(config.currentOrgName || "None")}`);
|
|
49
|
+
console.log(`API Base URL: ${colors.dim(config.apiBaseUrl)}`);
|
|
50
|
+
console.log(colors.dim("\n⚠ Could not fetch additional info from API"));
|
|
51
|
+
}
|
|
52
|
+
});
|
package/dist/index.d.ts
ADDED