@everystack/cli 0.1.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/README.md +255 -0
- package/package.json +104 -0
- package/src/cli/aws.ts +121 -0
- package/src/cli/commands/analyze.ts +61 -0
- package/src/cli/commands/branches.ts +97 -0
- package/src/cli/commands/cache.ts +72 -0
- package/src/cli/commands/certs.ts +117 -0
- package/src/cli/commands/channels.ts +109 -0
- package/src/cli/commands/console.ts +68 -0
- package/src/cli/commands/db.ts +183 -0
- package/src/cli/commands/diag.ts +242 -0
- package/src/cli/commands/logs.ts +282 -0
- package/src/cli/commands/update.ts +432 -0
- package/src/cli/config.ts +98 -0
- package/src/cli/discover.ts +321 -0
- package/src/cli/hydration-analyzer.ts +224 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/output.ts +25 -0
- package/src/cli/ssr-analyzer.ts +445 -0
- package/src/cli/utils/export.ts +8 -0
- package/src/cli/utils/table.ts +39 -0
- package/src/cli/utils/upload.ts +52 -0
- package/src/cli/utils/walk.ts +59 -0
- package/src/client/app-state-provider.tsx +83 -0
- package/src/client/index.ts +2 -0
- package/src/client/updates-provider.tsx +69 -0
- package/src/handler/assets.ts +30 -0
- package/src/handler/branches.ts +70 -0
- package/src/handler/channels-crud.ts +174 -0
- package/src/handler/helpers.ts +239 -0
- package/src/handler/index.ts +78 -0
- package/src/handler/manifest.ts +276 -0
- package/src/handler/multipart.ts +74 -0
- package/src/handler/publish-web.ts +311 -0
- package/src/handler/publish.ts +346 -0
- package/src/handler/signing.ts +29 -0
- package/src/handler/types.ts +16 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +245 -0
- package/src/storage/filesystem.ts +103 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/s3.ts +125 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface UploadPayload {
|
|
2
|
+
channel: string;
|
|
3
|
+
branch?: string;
|
|
4
|
+
groupId?: string;
|
|
5
|
+
runtimeVersion: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
message: string;
|
|
8
|
+
metadata: Record<string, unknown>;
|
|
9
|
+
expoConfig?: Record<string, unknown>;
|
|
10
|
+
assets: Array<{
|
|
11
|
+
path: string;
|
|
12
|
+
data: string;
|
|
13
|
+
contentType: string;
|
|
14
|
+
fileExtension: string;
|
|
15
|
+
isLaunchAsset: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
token?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UploadResult {
|
|
21
|
+
releaseId?: string;
|
|
22
|
+
groupId?: string;
|
|
23
|
+
branch?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function uploadToServer(url: string, payload: UploadPayload): Promise<UploadResult> {
|
|
28
|
+
const { token, ...body } = payload;
|
|
29
|
+
|
|
30
|
+
const headers: Record<string, string> = {
|
|
31
|
+
'content-type': 'application/json',
|
|
32
|
+
};
|
|
33
|
+
if (token) {
|
|
34
|
+
headers['authorization'] = `Bearer ${token}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers,
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Publish failed for ${payload.platform}: ${(errorBody as any).error || response.status}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = await response.json().catch(() => ({}));
|
|
51
|
+
return result as UploadResult;
|
|
52
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface WalkedFile {
|
|
5
|
+
relativePath: string;
|
|
6
|
+
data: string; // base64-encoded
|
|
7
|
+
contentType: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MIME_MAP: Record<string, string> = {
|
|
11
|
+
'.js': 'application/javascript',
|
|
12
|
+
'.mjs': 'application/javascript',
|
|
13
|
+
'.cjs': 'application/javascript',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.html': 'text/html',
|
|
16
|
+
'.css': 'text/css',
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.jpg': 'image/jpeg',
|
|
19
|
+
'.jpeg': 'image/jpeg',
|
|
20
|
+
'.gif': 'image/gif',
|
|
21
|
+
'.svg': 'image/svg+xml',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.ttf': 'font/ttf',
|
|
24
|
+
'.otf': 'font/otf',
|
|
25
|
+
'.woff': 'font/woff',
|
|
26
|
+
'.woff2': 'font/woff2',
|
|
27
|
+
'.map': 'application/json',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function mimeFromPath(filePath: string): string {
|
|
31
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
32
|
+
return MIME_MAP[ext] || 'application/octet-stream';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function walkDirectory(dir: string): Promise<WalkedFile[]> {
|
|
36
|
+
const results: WalkedFile[] = [];
|
|
37
|
+
await walkRecursive(dir, dir, results);
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function walkRecursive(rootDir: string, currentDir: string, results: WalkedFile[]): Promise<void> {
|
|
42
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
43
|
+
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
46
|
+
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
await walkRecursive(rootDir, fullPath, results);
|
|
49
|
+
} else if (entry.isFile()) {
|
|
50
|
+
const data = await fs.readFile(fullPath);
|
|
51
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
52
|
+
results.push({
|
|
53
|
+
relativePath,
|
|
54
|
+
data: data.toString('base64'),
|
|
55
|
+
contentType: mimeFromPath(fullPath),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface AppStateUpdateProviderProps {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
checkInterval?: number;
|
|
6
|
+
autoReload?: boolean;
|
|
7
|
+
onUpdateApplied?: () => void;
|
|
8
|
+
onError?: (error: Error) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AppStateContextValue {
|
|
12
|
+
updateApp: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const AppStateContext = createContext<AppStateContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function AppStateUpdateProvider({
|
|
18
|
+
children,
|
|
19
|
+
checkInterval = 60000,
|
|
20
|
+
autoReload = true,
|
|
21
|
+
onUpdateApplied,
|
|
22
|
+
onError,
|
|
23
|
+
}: AppStateUpdateProviderProps) {
|
|
24
|
+
const lastCheckRef = useRef<number>(0);
|
|
25
|
+
|
|
26
|
+
const updateApp = useCallback(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const Updates = await import('expo-updates');
|
|
29
|
+
const result = await Updates.checkForUpdateAsync();
|
|
30
|
+
|
|
31
|
+
if (result.isAvailable) {
|
|
32
|
+
await Updates.fetchUpdateAsync();
|
|
33
|
+
if (autoReload) {
|
|
34
|
+
await Updates.reloadAsync();
|
|
35
|
+
onUpdateApplied?.();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
40
|
+
onError?.(e);
|
|
41
|
+
}
|
|
42
|
+
}, [autoReload, onUpdateApplied, onError]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
let AppState: any;
|
|
46
|
+
let subscription: any;
|
|
47
|
+
|
|
48
|
+
(async () => {
|
|
49
|
+
try {
|
|
50
|
+
const RN = await import('react-native');
|
|
51
|
+
AppState = RN.AppState;
|
|
52
|
+
|
|
53
|
+
subscription = AppState.addEventListener('change', async (nextState: string) => {
|
|
54
|
+
if (nextState === 'active') {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
if (now - lastCheckRef.current >= checkInterval) {
|
|
57
|
+
lastCheckRef.current = now;
|
|
58
|
+
await updateApp();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// react-native not available (SSR/web)
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
subscription?.remove?.();
|
|
69
|
+
};
|
|
70
|
+
}, [checkInterval, updateApp]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<AppStateContext.Provider value={{ updateApp }}>
|
|
74
|
+
{children}
|
|
75
|
+
</AppStateContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useAppState(): AppStateContextValue {
|
|
80
|
+
const ctx = useContext(AppStateContext);
|
|
81
|
+
if (!ctx) throw new Error('useAppState must be used within AppStateUpdateProvider');
|
|
82
|
+
return ctx;
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface UpdatesContextValue {
|
|
4
|
+
checkForUpdate: () => Promise<void>;
|
|
5
|
+
isChecking: boolean;
|
|
6
|
+
isDownloading: boolean;
|
|
7
|
+
availableUpdate: { id: string; createdAt: string } | null;
|
|
8
|
+
error: Error | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const UpdatesContext = createContext<UpdatesContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
export interface UpdatesProviderProps {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
onUpdateAvailable?: (update: { id: string; createdAt: string }) => void;
|
|
16
|
+
onError?: (error: Error) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function UpdatesProvider({ children, onUpdateAvailable, onError }: UpdatesProviderProps) {
|
|
20
|
+
const [isChecking, setIsChecking] = useState(false);
|
|
21
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
22
|
+
const [availableUpdate, setAvailableUpdate] = useState<{ id: string; createdAt: string } | null>(null);
|
|
23
|
+
const [error, setError] = useState<Error | null>(null);
|
|
24
|
+
|
|
25
|
+
const checkForUpdate = useCallback(async () => {
|
|
26
|
+
setIsChecking(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const Updates = await import('expo-updates');
|
|
31
|
+
const result = await Updates.checkForUpdateAsync();
|
|
32
|
+
|
|
33
|
+
if (result.isAvailable) {
|
|
34
|
+
const manifest = result.manifest as any;
|
|
35
|
+
const update = { id: manifest?.id || '', createdAt: manifest?.createdAt || '' };
|
|
36
|
+
setAvailableUpdate(update);
|
|
37
|
+
onUpdateAvailable?.(update);
|
|
38
|
+
|
|
39
|
+
setIsDownloading(true);
|
|
40
|
+
await Updates.fetchUpdateAsync();
|
|
41
|
+
setIsDownloading(false);
|
|
42
|
+
await Updates.reloadAsync();
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
46
|
+
setError(e);
|
|
47
|
+
onError?.(e);
|
|
48
|
+
} finally {
|
|
49
|
+
setIsChecking(false);
|
|
50
|
+
setIsDownloading(false);
|
|
51
|
+
}
|
|
52
|
+
}, [onUpdateAvailable, onError]);
|
|
53
|
+
|
|
54
|
+
const value: UpdatesContextValue = {
|
|
55
|
+
checkForUpdate,
|
|
56
|
+
isChecking,
|
|
57
|
+
isDownloading,
|
|
58
|
+
availableUpdate,
|
|
59
|
+
error,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return <UpdatesContext.Provider value={value}>{children}</UpdatesContext.Provider>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useUpdates(): UpdatesContextValue {
|
|
66
|
+
const ctx = useContext(UpdatesContext);
|
|
67
|
+
if (!ctx) throw new Error('useUpdates must be used within UpdatesProvider');
|
|
68
|
+
return ctx;
|
|
69
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { UpdatesHandlerOptions } from './types';
|
|
2
|
+
|
|
3
|
+
export async function handleAssets(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
|
|
4
|
+
const url = new URL(request.url);
|
|
5
|
+
const key = url.searchParams.get('key');
|
|
6
|
+
|
|
7
|
+
if (!key) {
|
|
8
|
+
return new Response(JSON.stringify({ error: 'No asset key provided.' }), {
|
|
9
|
+
status: 400,
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const result = await options.storage.get(key);
|
|
15
|
+
|
|
16
|
+
if (!result) {
|
|
17
|
+
return new Response(JSON.stringify({ error: `Asset "${key}" not found.` }), {
|
|
18
|
+
status: 404,
|
|
19
|
+
headers: { 'content-type': 'application/json' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new Response(new Uint8Array(result.data), {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: {
|
|
26
|
+
'content-type': result.contentType,
|
|
27
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { UpdatesHandlerOptions } from './types';
|
|
2
|
+
import { checkAuth, jsonError } from './helpers';
|
|
3
|
+
|
|
4
|
+
export async function handleListBranches(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
|
|
5
|
+
// List all keys under releases/ and extract unique branch names
|
|
6
|
+
const keys = await options.storage.list('releases/');
|
|
7
|
+
const branchNames = new Set<string>();
|
|
8
|
+
|
|
9
|
+
for (const key of keys) {
|
|
10
|
+
// Keys: releases/{branchName}/{runtimeVersion}/{groupId}/{platform}/...
|
|
11
|
+
const parts = key.split('/');
|
|
12
|
+
if (parts.length >= 2 && parts[1]) {
|
|
13
|
+
branchNames.add(parts[1]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const branches = [...branchNames].sort().map(name => ({ name }));
|
|
18
|
+
|
|
19
|
+
return new Response(JSON.stringify({ branches }), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function handleCreateBranch(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
|
|
26
|
+
const authResult = await checkAuth(request, options);
|
|
27
|
+
if (authResult) return authResult;
|
|
28
|
+
|
|
29
|
+
let body: { name?: string };
|
|
30
|
+
try {
|
|
31
|
+
body = await request.json() as { name?: string };
|
|
32
|
+
} catch {
|
|
33
|
+
return jsonError(400, 'Invalid JSON body.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!body.name) {
|
|
37
|
+
return jsonError(400, 'Missing name.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check if branch already has releases in S3
|
|
41
|
+
const keys = await options.storage.list(`releases/${body.name}/`);
|
|
42
|
+
if (keys.length > 0) {
|
|
43
|
+
return jsonError(409, `Branch "${body.name}" already exists.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Branches are virtual — return acknowledgment (materializes on first publish)
|
|
47
|
+
return new Response(JSON.stringify({
|
|
48
|
+
name: body.name,
|
|
49
|
+
createdAt: new Date().toISOString(),
|
|
50
|
+
}), {
|
|
51
|
+
status: 201,
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function handleDeleteBranch(request: Request, options: UpdatesHandlerOptions, branchName: string): Promise<Response> {
|
|
57
|
+
const authResult = await checkAuth(request, options);
|
|
58
|
+
if (authResult) return authResult;
|
|
59
|
+
|
|
60
|
+
// Check S3 for releases under this branch
|
|
61
|
+
const keys = await options.storage.list(`releases/${branchName}/`);
|
|
62
|
+
if (keys.length > 0) {
|
|
63
|
+
return jsonError(409, `Branch "${branchName}" has releases. Delete releases first.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return new Response(JSON.stringify({ deleted: true }), {
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { eq, and } from 'drizzle-orm';
|
|
2
|
+
import type { UpdatesHandlerOptions } from './types';
|
|
3
|
+
import { opsItems } from '../schema';
|
|
4
|
+
import {
|
|
5
|
+
checkAuth,
|
|
6
|
+
jsonError,
|
|
7
|
+
mirrorToDb,
|
|
8
|
+
updateChannelPointers,
|
|
9
|
+
type ChannelMetadata,
|
|
10
|
+
} from './helpers';
|
|
11
|
+
|
|
12
|
+
export async function handleListChannels(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
|
|
13
|
+
// List channels from S3 _channels/ directory
|
|
14
|
+
const keys = await options.storage.list('_channels/');
|
|
15
|
+
const channels: ChannelMetadata[] = [];
|
|
16
|
+
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
if (!key.endsWith('.json')) continue;
|
|
19
|
+
try {
|
|
20
|
+
const result = await options.storage.get(key);
|
|
21
|
+
if (result) {
|
|
22
|
+
channels.push(JSON.parse(result.data.toString('utf8')));
|
|
23
|
+
}
|
|
24
|
+
} catch { /* skip malformed */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Sort by createdAt descending
|
|
28
|
+
channels.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
|
|
29
|
+
|
|
30
|
+
// Shape response for backward compatibility
|
|
31
|
+
const shaped = channels.map(ch => ({
|
|
32
|
+
name: ch.name,
|
|
33
|
+
type: 'release_channel',
|
|
34
|
+
status: ch.status,
|
|
35
|
+
data: { branchRef: ch.branchRef },
|
|
36
|
+
createdAt: ch.createdAt,
|
|
37
|
+
updatedAt: ch.updatedAt,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
return new Response(JSON.stringify({ channels: shaped }), {
|
|
41
|
+
status: 200,
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function handleCreateChannel(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
|
|
47
|
+
const authResult = await checkAuth(request, options);
|
|
48
|
+
if (authResult) return authResult;
|
|
49
|
+
|
|
50
|
+
let body: { name?: string; branchName?: string };
|
|
51
|
+
try {
|
|
52
|
+
body = await request.json() as { name?: string; branchName?: string };
|
|
53
|
+
} catch {
|
|
54
|
+
return jsonError(400, 'Invalid JSON body.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!body.name) {
|
|
58
|
+
return jsonError(400, 'Missing name.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check S3 for existing channel
|
|
62
|
+
const existingKey = `_channels/${body.name}.json`;
|
|
63
|
+
const exists = await options.storage.exists(existingKey);
|
|
64
|
+
if (exists) {
|
|
65
|
+
return jsonError(409, `Channel "${body.name}" already exists.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const branchRef = body.branchName || body.name;
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
|
|
71
|
+
// Write to S3
|
|
72
|
+
const channelMeta: ChannelMetadata = {
|
|
73
|
+
name: body.name,
|
|
74
|
+
branchRef,
|
|
75
|
+
status: 'active',
|
|
76
|
+
createdAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
};
|
|
79
|
+
await options.storage.put(existingKey, Buffer.from(JSON.stringify(channelMeta)), 'application/json');
|
|
80
|
+
|
|
81
|
+
// Mirror to DB
|
|
82
|
+
await mirrorToDb(options.db, async () => {
|
|
83
|
+
await options.db
|
|
84
|
+
.insert(opsItems)
|
|
85
|
+
.values({
|
|
86
|
+
type: 'release_channel',
|
|
87
|
+
name: body.name,
|
|
88
|
+
status: 'active',
|
|
89
|
+
data: { branchRef },
|
|
90
|
+
})
|
|
91
|
+
.returning();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return new Response(JSON.stringify({
|
|
95
|
+
name: channelMeta.name,
|
|
96
|
+
type: 'release_channel',
|
|
97
|
+
status: channelMeta.status,
|
|
98
|
+
data: { branchRef },
|
|
99
|
+
createdAt: now,
|
|
100
|
+
updatedAt: now,
|
|
101
|
+
}), {
|
|
102
|
+
status: 201,
|
|
103
|
+
headers: { 'content-type': 'application/json' },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function handleEditChannel(request: Request, options: UpdatesHandlerOptions, channelName: string): Promise<Response> {
|
|
108
|
+
const authResult = await checkAuth(request, options);
|
|
109
|
+
if (authResult) return authResult;
|
|
110
|
+
|
|
111
|
+
let body: { branchName?: string };
|
|
112
|
+
try {
|
|
113
|
+
body = await request.json() as { branchName?: string };
|
|
114
|
+
} catch {
|
|
115
|
+
return jsonError(400, 'Invalid JSON body.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!body.branchName) {
|
|
119
|
+
return jsonError(400, 'Missing branchName.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read channel from S3
|
|
123
|
+
const key = `_channels/${channelName}.json`;
|
|
124
|
+
const result = await options.storage.get(key);
|
|
125
|
+
if (!result) {
|
|
126
|
+
return jsonError(404, `Channel "${channelName}" not found.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const channelMeta = JSON.parse(result.data.toString('utf8')) as ChannelMetadata;
|
|
130
|
+
channelMeta.branchRef = body.branchName;
|
|
131
|
+
channelMeta.updatedAt = new Date().toISOString();
|
|
132
|
+
|
|
133
|
+
// Write back to S3
|
|
134
|
+
await options.storage.put(key, Buffer.from(JSON.stringify(channelMeta)), 'application/json');
|
|
135
|
+
|
|
136
|
+
// Regenerate channel pointers for the new branch
|
|
137
|
+
await updateChannelPointers(options.storage, channelName, body.branchName);
|
|
138
|
+
|
|
139
|
+
// Mirror to DB
|
|
140
|
+
await mirrorToDb(options.db, async () => {
|
|
141
|
+
const [channel] = await options.db
|
|
142
|
+
.select()
|
|
143
|
+
.from(opsItems)
|
|
144
|
+
.where(and(
|
|
145
|
+
eq(opsItems.type, 'release_channel'),
|
|
146
|
+
eq(opsItems.name, channelName),
|
|
147
|
+
))
|
|
148
|
+
.limit(1);
|
|
149
|
+
|
|
150
|
+
if (channel) {
|
|
151
|
+
const existingData = (channel.data as Record<string, unknown>) || {};
|
|
152
|
+
await options.db
|
|
153
|
+
.update(opsItems)
|
|
154
|
+
.set({
|
|
155
|
+
data: { ...existingData, branchRef: body.branchName },
|
|
156
|
+
updatedAt: new Date(),
|
|
157
|
+
})
|
|
158
|
+
.where(eq(opsItems.id, channel.id))
|
|
159
|
+
.returning();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return new Response(JSON.stringify({
|
|
164
|
+
name: channelMeta.name,
|
|
165
|
+
type: 'release_channel',
|
|
166
|
+
status: channelMeta.status,
|
|
167
|
+
data: { branchRef: channelMeta.branchRef },
|
|
168
|
+
createdAt: channelMeta.createdAt,
|
|
169
|
+
updatedAt: channelMeta.updatedAt,
|
|
170
|
+
}), {
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: { 'content-type': 'application/json' },
|
|
173
|
+
});
|
|
174
|
+
}
|