showpane 0.4.23 → 0.4.24
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/bundle/meta/scaffold-manifest.json +3 -3
- package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +20 -0
- package/bundle/scaffold/src/lib/portal-contracts.ts +2 -0
- package/bundle/toolchain/CLI_VERSION +1 -1
- package/bundle/toolchain/bin/deploy-to-cloud.ts +761 -0
- package/bundle/toolchain/bin/ensure-cloud-project-link.ts +2 -0
- package/bundle/toolchain/skills/portal-deploy/SKILL.md +37 -325
- package/bundle/toolchain/skills/portal-deploy/SKILL.md.tmpl +37 -325
- package/dist/index.js +125 -1
- package/package.json +1 -1
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
type ShowpaneConfig = {
|
|
8
|
+
accessToken?: string;
|
|
9
|
+
accessTokenExpiresAt?: string | null;
|
|
10
|
+
app_path?: string;
|
|
11
|
+
deploy_mode?: string;
|
|
12
|
+
orgSlug?: string;
|
|
13
|
+
portalUrl?: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type RuntimeStatePayload = {
|
|
17
|
+
organization?: {
|
|
18
|
+
slug?: string;
|
|
19
|
+
};
|
|
20
|
+
portals?: Array<{
|
|
21
|
+
slug: string;
|
|
22
|
+
isActive: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type FileManifestEntry = {
|
|
27
|
+
portalSlug: string;
|
|
28
|
+
storagePath: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
mimeType: string;
|
|
31
|
+
size: number;
|
|
32
|
+
uploadedBy: string;
|
|
33
|
+
uploadedAt: string;
|
|
34
|
+
checksum: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type DeploymentInitResponse = {
|
|
38
|
+
deploymentId: string;
|
|
39
|
+
status: string;
|
|
40
|
+
artifactStoragePath: string;
|
|
41
|
+
artifactUploadUrl: string | null;
|
|
42
|
+
artifactUploaded: boolean;
|
|
43
|
+
liveUrl: string | null;
|
|
44
|
+
missingFiles?: FileManifestEntry[];
|
|
45
|
+
error?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type DeploymentFinalizeResponse = {
|
|
49
|
+
deploymentId: string;
|
|
50
|
+
status: string;
|
|
51
|
+
liveUrl: string | null;
|
|
52
|
+
inspectorUrl: string | null;
|
|
53
|
+
prebuilt: boolean | null;
|
|
54
|
+
buildSkipped: boolean | null;
|
|
55
|
+
error?: string | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type DeploymentStatusResponse = {
|
|
59
|
+
deploymentId: string;
|
|
60
|
+
status: string;
|
|
61
|
+
liveUrl: string | null;
|
|
62
|
+
inspectorUrl: string | null;
|
|
63
|
+
prebuilt: boolean | null;
|
|
64
|
+
buildSkipped: boolean | null;
|
|
65
|
+
error?: string | null;
|
|
66
|
+
terminal?: boolean;
|
|
67
|
+
retryable?: boolean;
|
|
68
|
+
pollAfterMs?: number | null;
|
|
69
|
+
nextAction?: string;
|
|
70
|
+
artifactUploaded?: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type DeployResult = {
|
|
74
|
+
ok: true;
|
|
75
|
+
deploymentId: string;
|
|
76
|
+
status: string;
|
|
77
|
+
liveUrl: string | null;
|
|
78
|
+
inspectorUrl: string | null;
|
|
79
|
+
portalCount: number;
|
|
80
|
+
firstPortalSlug: string | null;
|
|
81
|
+
fileSyncCount: number;
|
|
82
|
+
verification: {
|
|
83
|
+
portalStatus: number | null;
|
|
84
|
+
healthStatus: number | null;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type DeployFailure = {
|
|
89
|
+
ok: false;
|
|
90
|
+
step: string;
|
|
91
|
+
error: string;
|
|
92
|
+
detail?: unknown;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type DeployArgs = {
|
|
96
|
+
appPath?: string;
|
|
97
|
+
wait: boolean;
|
|
98
|
+
json: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
const SHOWPANE_HOME = path.join(os.homedir(), ".showpane");
|
|
103
|
+
const DEFAULT_API_BASE = process.env.SHOWPANE_CLOUD_URL || "https://app.showpane.com";
|
|
104
|
+
const MAX_BUFFER = 50 * 1024 * 1024;
|
|
105
|
+
const MAX_WAIT_MS = 5 * 60 * 1000;
|
|
106
|
+
const MAX_STATUS_ERROR_RETRIES = 3;
|
|
107
|
+
|
|
108
|
+
function parseArgs(argv: string[]): DeployArgs {
|
|
109
|
+
const getArg = (flag: string) => {
|
|
110
|
+
const index = argv.indexOf(flag);
|
|
111
|
+
return index !== -1 ? argv[index + 1] : undefined;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (argv.includes("--help")) {
|
|
115
|
+
console.log("Usage: deploy-to-cloud [--app-path <path>] [--wait] [--json]");
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
appPath: getArg("--app-path"),
|
|
121
|
+
wait: argv.includes("--wait"),
|
|
122
|
+
json: argv.includes("--json"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readConfig(): ShowpaneConfig {
|
|
127
|
+
const configPath = path.join(SHOWPANE_HOME, "config.json");
|
|
128
|
+
if (!fs.existsSync(configPath)) {
|
|
129
|
+
throw new Error("Showpane not configured. Run showpane login.");
|
|
130
|
+
}
|
|
131
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8")) as ShowpaneConfig;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function out(message: string, json: boolean) {
|
|
135
|
+
(json ? process.stderr : process.stdout).write(`${message}\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ok(result: DeployResult, json: boolean): never {
|
|
139
|
+
if (json) {
|
|
140
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
141
|
+
} else {
|
|
142
|
+
console.log();
|
|
143
|
+
console.log("Cloud deploy complete");
|
|
144
|
+
console.log();
|
|
145
|
+
console.log(` Status: ${result.status}`);
|
|
146
|
+
console.log(` Deploy ID: ${result.deploymentId}`);
|
|
147
|
+
console.log(` Live URL: ${result.liveUrl ?? "pending"}`);
|
|
148
|
+
console.log(` Portals: ${result.portalCount}`);
|
|
149
|
+
console.log(` File sync: ${result.fileSyncCount}`);
|
|
150
|
+
if (result.inspectorUrl) {
|
|
151
|
+
console.log(` Inspector: ${result.inspectorUrl}`);
|
|
152
|
+
}
|
|
153
|
+
if (result.firstPortalSlug) {
|
|
154
|
+
console.log(` First: ${result.firstPortalSlug}`);
|
|
155
|
+
}
|
|
156
|
+
console.log();
|
|
157
|
+
}
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fail(step: string, error: string, json: boolean, detail?: unknown): never {
|
|
162
|
+
const payload: DeployFailure = { ok: false, step, error, detail };
|
|
163
|
+
if (json) {
|
|
164
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
165
|
+
} else {
|
|
166
|
+
console.error(`ERROR [${step}]: ${error}`);
|
|
167
|
+
if (detail !== undefined) {
|
|
168
|
+
console.error(typeof detail === "string" ? detail : JSON.stringify(detail, null, 2));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveAppPath(config: ShowpaneConfig, args: DeployArgs): string {
|
|
175
|
+
if (args.appPath) {
|
|
176
|
+
return path.resolve(args.appPath);
|
|
177
|
+
}
|
|
178
|
+
if (process.env.SHOWPANE_APP_PATH) {
|
|
179
|
+
return path.resolve(process.env.SHOWPANE_APP_PATH);
|
|
180
|
+
}
|
|
181
|
+
if (config.app_path) {
|
|
182
|
+
return path.resolve(config.app_path);
|
|
183
|
+
}
|
|
184
|
+
return process.cwd();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function shouldSkipCopy(relativePath: string): boolean {
|
|
188
|
+
return (
|
|
189
|
+
relativePath === ".next" ||
|
|
190
|
+
relativePath.startsWith(`.next${path.sep}`) ||
|
|
191
|
+
relativePath === "node_modules" ||
|
|
192
|
+
relativePath.startsWith(`node_modules${path.sep}`) ||
|
|
193
|
+
relativePath === path.join(".vercel", "output") ||
|
|
194
|
+
relativePath.startsWith(path.join(".vercel", "output") + path.sep)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function copyWorkspaceForBuild(sourcePath: string): {
|
|
199
|
+
path: string;
|
|
200
|
+
cleanup(): void;
|
|
201
|
+
} {
|
|
202
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "showpane-cloud-build-"));
|
|
203
|
+
const buildPath = path.join(tempRoot, path.basename(sourcePath));
|
|
204
|
+
fs.cpSync(sourcePath, buildPath, {
|
|
205
|
+
recursive: true,
|
|
206
|
+
dereference: false,
|
|
207
|
+
filter: (src) => {
|
|
208
|
+
const relativePath = path.relative(sourcePath, src);
|
|
209
|
+
if (!relativePath) return true;
|
|
210
|
+
return !shouldSkipCopy(relativePath);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const sourceNodeModules = path.join(sourcePath, "node_modules");
|
|
215
|
+
if (fs.existsSync(sourceNodeModules)) {
|
|
216
|
+
fs.symlinkSync(sourceNodeModules, path.join(buildPath, "node_modules"), "dir");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
path: buildPath,
|
|
221
|
+
cleanup() {
|
|
222
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function runCommand(
|
|
228
|
+
command: string,
|
|
229
|
+
args: string[],
|
|
230
|
+
options: {
|
|
231
|
+
cwd: string;
|
|
232
|
+
env?: NodeJS.ProcessEnv;
|
|
233
|
+
json: boolean;
|
|
234
|
+
step: string;
|
|
235
|
+
},
|
|
236
|
+
) {
|
|
237
|
+
const result = spawnSync(command, args, {
|
|
238
|
+
cwd: options.cwd,
|
|
239
|
+
env: { ...process.env, ...options.env },
|
|
240
|
+
encoding: "utf8",
|
|
241
|
+
maxBuffer: MAX_BUFFER,
|
|
242
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (result.status !== 0) {
|
|
246
|
+
fail(
|
|
247
|
+
options.step,
|
|
248
|
+
`${command} ${args.join(" ")} failed`,
|
|
249
|
+
options.json,
|
|
250
|
+
(result.stderr || result.stdout || "").trim(),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!options.json) {
|
|
255
|
+
if (result.stdout?.trim()) process.stdout.write(result.stdout);
|
|
256
|
+
if (result.stderr?.trim()) process.stderr.write(result.stderr);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return result.stdout?.trim() ?? "";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function runToolchainTsScript<T>(
|
|
263
|
+
appPath: string,
|
|
264
|
+
scriptName: string,
|
|
265
|
+
args: string[],
|
|
266
|
+
json: boolean,
|
|
267
|
+
): T {
|
|
268
|
+
const appTsconfigPath = path.join(appPath, "tsconfig.json");
|
|
269
|
+
const output = runCommand(
|
|
270
|
+
"npx",
|
|
271
|
+
[
|
|
272
|
+
"tsx",
|
|
273
|
+
"--tsconfig",
|
|
274
|
+
appTsconfigPath,
|
|
275
|
+
path.join(scriptDir, scriptName),
|
|
276
|
+
...args,
|
|
277
|
+
],
|
|
278
|
+
{
|
|
279
|
+
cwd: appPath,
|
|
280
|
+
env: {
|
|
281
|
+
NODE_PATH: path.join(appPath, "node_modules"),
|
|
282
|
+
},
|
|
283
|
+
json,
|
|
284
|
+
step: scriptName,
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(output) as T;
|
|
290
|
+
} catch {
|
|
291
|
+
fail(scriptName, "Expected JSON output from helper script", json, output);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function fetchJson<T>(
|
|
296
|
+
input: string,
|
|
297
|
+
init: RequestInit,
|
|
298
|
+
json: boolean,
|
|
299
|
+
step: string,
|
|
300
|
+
): Promise<T> {
|
|
301
|
+
let response: Response;
|
|
302
|
+
try {
|
|
303
|
+
response = await fetch(input, init);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
fail(step, error instanceof Error ? error.message : String(error), json);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const text = await response.text();
|
|
309
|
+
let payload: unknown = null;
|
|
310
|
+
if (text) {
|
|
311
|
+
try {
|
|
312
|
+
payload = JSON.parse(text);
|
|
313
|
+
} catch {
|
|
314
|
+
payload = text;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const detail = payload ?? text;
|
|
320
|
+
const parsedError =
|
|
321
|
+
payload && typeof payload === "object" && "error" in (payload as Record<string, unknown>)
|
|
322
|
+
? (payload as Record<string, unknown>).error
|
|
323
|
+
: null;
|
|
324
|
+
fail(
|
|
325
|
+
step,
|
|
326
|
+
typeof parsedError === "string" && parsedError
|
|
327
|
+
? parsedError
|
|
328
|
+
: `HTTP ${response.status}`,
|
|
329
|
+
json,
|
|
330
|
+
detail,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return (payload ?? {}) as T;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildHeaders(token: string) {
|
|
338
|
+
return {
|
|
339
|
+
Authorization: `Bearer ${token}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function normalizeDeploymentStatus(
|
|
344
|
+
response: DeploymentStatusResponse,
|
|
345
|
+
): Required<Pick<DeploymentStatusResponse, "terminal" | "retryable" | "pollAfterMs" | "nextAction" | "artifactUploaded">> &
|
|
346
|
+
DeploymentStatusResponse {
|
|
347
|
+
const status = response.status;
|
|
348
|
+
const inferredTerminal = status === "live" || status === "failed" || status === "unhealthy";
|
|
349
|
+
const inferredRetryable = !inferredTerminal;
|
|
350
|
+
const inferredPollAfterMs = inferredTerminal ? null : 5_000;
|
|
351
|
+
const inferredNextAction =
|
|
352
|
+
status === "live"
|
|
353
|
+
? "done"
|
|
354
|
+
: status === "awaiting_upload"
|
|
355
|
+
? "upload_artifact"
|
|
356
|
+
: status === "failed" || status === "unhealthy"
|
|
357
|
+
? response.inspectorUrl
|
|
358
|
+
? "open_inspector"
|
|
359
|
+
: "retry_deploy"
|
|
360
|
+
: "wait";
|
|
361
|
+
const inferredArtifactUploaded =
|
|
362
|
+
response.artifactUploaded ??
|
|
363
|
+
status !== "awaiting_upload";
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...response,
|
|
367
|
+
terminal: response.terminal ?? inferredTerminal,
|
|
368
|
+
retryable: response.retryable ?? inferredRetryable,
|
|
369
|
+
pollAfterMs:
|
|
370
|
+
response.pollAfterMs === undefined
|
|
371
|
+
? inferredPollAfterMs
|
|
372
|
+
: response.pollAfterMs,
|
|
373
|
+
nextAction: response.nextAction ?? inferredNextAction,
|
|
374
|
+
artifactUploaded: inferredArtifactUploaded,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function verifyUrl(
|
|
379
|
+
url: string,
|
|
380
|
+
acceptableStatuses: number[],
|
|
381
|
+
): Promise<number | null> {
|
|
382
|
+
try {
|
|
383
|
+
const response = await fetch(url, { redirect: "manual" });
|
|
384
|
+
return acceptableStatuses.includes(response.status) ? response.status : response.status;
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function uploadMissingFiles(
|
|
391
|
+
appPath: string,
|
|
392
|
+
files: FileManifestEntry[],
|
|
393
|
+
token: string,
|
|
394
|
+
apiBase: string,
|
|
395
|
+
json: boolean,
|
|
396
|
+
): Promise<number> {
|
|
397
|
+
if (files.length === 0) {
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "showpane-file-sync-"));
|
|
402
|
+
try {
|
|
403
|
+
for (const file of files) {
|
|
404
|
+
const tempPath = path.join(tempDir, file.checksum);
|
|
405
|
+
const appTsconfigPath = path.join(appPath, "tsconfig.json");
|
|
406
|
+
runCommand(
|
|
407
|
+
"npx",
|
|
408
|
+
[
|
|
409
|
+
"tsx",
|
|
410
|
+
"--tsconfig",
|
|
411
|
+
appTsconfigPath,
|
|
412
|
+
path.join(scriptDir, "materialize-file.ts"),
|
|
413
|
+
"--storage-path",
|
|
414
|
+
file.storagePath,
|
|
415
|
+
"--output",
|
|
416
|
+
tempPath,
|
|
417
|
+
],
|
|
418
|
+
{
|
|
419
|
+
cwd: appPath,
|
|
420
|
+
env: {
|
|
421
|
+
NODE_PATH: path.join(appPath, "node_modules"),
|
|
422
|
+
},
|
|
423
|
+
json,
|
|
424
|
+
step: `materialize-file:${file.filename}`,
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const formData = new FormData();
|
|
429
|
+
formData.append(
|
|
430
|
+
"file",
|
|
431
|
+
new Blob([fs.readFileSync(tempPath)], { type: file.mimeType }),
|
|
432
|
+
file.filename,
|
|
433
|
+
);
|
|
434
|
+
formData.append("storagePath", file.storagePath);
|
|
435
|
+
formData.append("portalSlug", file.portalSlug);
|
|
436
|
+
formData.append("filename", file.filename);
|
|
437
|
+
formData.append("mimeType", file.mimeType);
|
|
438
|
+
formData.append("size", String(file.size));
|
|
439
|
+
formData.append("uploadedBy", file.uploadedBy);
|
|
440
|
+
formData.append("uploadedAt", file.uploadedAt);
|
|
441
|
+
formData.append("checksum", file.checksum);
|
|
442
|
+
|
|
443
|
+
await fetchJson<{ ok: true }>(
|
|
444
|
+
`${apiBase}/api/files/upload`,
|
|
445
|
+
{
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: buildHeaders(token),
|
|
448
|
+
body: formData,
|
|
449
|
+
},
|
|
450
|
+
json,
|
|
451
|
+
`upload-file:${file.filename}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
} finally {
|
|
455
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return files.length;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function main() {
|
|
462
|
+
const args = parseArgs(process.argv.slice(2));
|
|
463
|
+
const config = readConfig();
|
|
464
|
+
const token = config.accessToken;
|
|
465
|
+
if (!token) {
|
|
466
|
+
fail("auth", "Missing cloud access token. Run showpane login.", args.json);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const apiBase = DEFAULT_API_BASE;
|
|
470
|
+
const sourceAppPath = resolveAppPath(config, args);
|
|
471
|
+
const portalUrl =
|
|
472
|
+
config.portalUrl ||
|
|
473
|
+
(typeof config.orgSlug === "string" && config.orgSlug
|
|
474
|
+
? `https://${config.orgSlug}.showpane.com`
|
|
475
|
+
: null);
|
|
476
|
+
|
|
477
|
+
out(`Deploying from ${sourceAppPath}`, args.json);
|
|
478
|
+
|
|
479
|
+
const health = await fetch(`${apiBase}/api/health`);
|
|
480
|
+
if (!health.ok) {
|
|
481
|
+
fail("health", `Showpane Cloud health check failed (${health.status})`, args.json);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
out("Type-checking app", args.json);
|
|
485
|
+
runCommand("npx", ["tsc", "--noEmit"], {
|
|
486
|
+
cwd: sourceAppPath,
|
|
487
|
+
json: args.json,
|
|
488
|
+
step: "typecheck",
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const buildWorkspace = copyWorkspaceForBuild(sourceAppPath);
|
|
492
|
+
const buildAppPath = buildWorkspace.path;
|
|
493
|
+
const artifactPath = path.join(os.tmpdir(), `showpane-deploy-${Date.now()}.zip`);
|
|
494
|
+
try {
|
|
495
|
+
const projectLinkPath = path.join(buildAppPath, ".vercel", "project.json");
|
|
496
|
+
let projectLinkValid = false;
|
|
497
|
+
if (fs.existsSync(projectLinkPath)) {
|
|
498
|
+
try {
|
|
499
|
+
const payload = JSON.parse(fs.readFileSync(projectLinkPath, "utf8")) as {
|
|
500
|
+
projectId?: string;
|
|
501
|
+
orgId?: string;
|
|
502
|
+
};
|
|
503
|
+
projectLinkValid = Boolean(payload.projectId && payload.orgId);
|
|
504
|
+
} catch {
|
|
505
|
+
projectLinkValid = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!projectLinkValid) {
|
|
510
|
+
out("Linking workspace to cloud project", args.json);
|
|
511
|
+
runToolchainTsScript<{ ok: boolean; projectId: string }>(
|
|
512
|
+
buildAppPath,
|
|
513
|
+
"ensure-cloud-project-link.ts",
|
|
514
|
+
[],
|
|
515
|
+
args.json,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
out("Building cloud artifact", args.json);
|
|
520
|
+
runCommand("npm", ["run", "cloud:build"], {
|
|
521
|
+
cwd: buildAppPath,
|
|
522
|
+
json: args.json,
|
|
523
|
+
step: "cloud-build",
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
out("Packaging deployment bundle", args.json);
|
|
527
|
+
runToolchainTsScript<{ ok: true; fileCount: number }>(
|
|
528
|
+
buildAppPath,
|
|
529
|
+
"create-deploy-bundle.ts",
|
|
530
|
+
["--output", artifactPath],
|
|
531
|
+
args.json,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
out("Exporting runtime state", args.json);
|
|
535
|
+
const runtimeData = runToolchainTsScript<RuntimeStatePayload>(
|
|
536
|
+
sourceAppPath,
|
|
537
|
+
"export-runtime-state.ts",
|
|
538
|
+
[],
|
|
539
|
+
args.json,
|
|
540
|
+
);
|
|
541
|
+
const portals = Array.isArray(runtimeData.portals) ? runtimeData.portals : [];
|
|
542
|
+
const portalCount = portals.length;
|
|
543
|
+
const firstPortalSlug =
|
|
544
|
+
portals.find((portal) => portal.isActive)?.slug ?? portals[0]?.slug ?? null;
|
|
545
|
+
|
|
546
|
+
out("Exporting file manifest", args.json);
|
|
547
|
+
const manifest = runToolchainTsScript<{ files: FileManifestEntry[] }>(
|
|
548
|
+
sourceAppPath,
|
|
549
|
+
"export-file-manifest.ts",
|
|
550
|
+
[],
|
|
551
|
+
args.json,
|
|
552
|
+
);
|
|
553
|
+
const manifestFiles = Array.isArray(manifest.files) ? manifest.files : [];
|
|
554
|
+
|
|
555
|
+
out("Initializing cloud deployment", args.json);
|
|
556
|
+
const init = await fetchJson<DeploymentInitResponse>(
|
|
557
|
+
`${apiBase}/api/deployments`,
|
|
558
|
+
{
|
|
559
|
+
method: "POST",
|
|
560
|
+
headers: {
|
|
561
|
+
...buildHeaders(token),
|
|
562
|
+
"Content-Type": "application/json",
|
|
563
|
+
},
|
|
564
|
+
body: JSON.stringify(manifestFiles.length > 0 ? { files: manifestFiles } : {}),
|
|
565
|
+
},
|
|
566
|
+
args.json,
|
|
567
|
+
"deployment-init",
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const missingFiles = Array.isArray(init.missingFiles) ? init.missingFiles : [];
|
|
571
|
+
let fileSyncCount = 0;
|
|
572
|
+
|
|
573
|
+
if (init.status === "awaiting_upload") {
|
|
574
|
+
if (missingFiles.length > 0) {
|
|
575
|
+
out(`Syncing ${missingFiles.length} hosted file(s)`, args.json);
|
|
576
|
+
fileSyncCount = await uploadMissingFiles(
|
|
577
|
+
sourceAppPath,
|
|
578
|
+
missingFiles,
|
|
579
|
+
token,
|
|
580
|
+
apiBase,
|
|
581
|
+
args.json,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!init.artifactUploadUrl) {
|
|
586
|
+
fail("artifact-upload", "Missing artifact upload URL", args.json, init);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
out("Uploading artifact", args.json);
|
|
590
|
+
const uploadResponse = await fetch(init.artifactUploadUrl, {
|
|
591
|
+
method: "PUT",
|
|
592
|
+
headers: {
|
|
593
|
+
"Content-Type": "application/zip",
|
|
594
|
+
},
|
|
595
|
+
body: fs.readFileSync(artifactPath),
|
|
596
|
+
});
|
|
597
|
+
if (!uploadResponse.ok) {
|
|
598
|
+
fail(
|
|
599
|
+
"artifact-upload",
|
|
600
|
+
`Artifact upload failed (${uploadResponse.status})`,
|
|
601
|
+
args.json,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
out("Finalizing deployment", args.json);
|
|
606
|
+
await fetchJson<DeploymentFinalizeResponse>(
|
|
607
|
+
`${apiBase}/api/deployments/${init.deploymentId}/finalize`,
|
|
608
|
+
{
|
|
609
|
+
method: "POST",
|
|
610
|
+
headers: {
|
|
611
|
+
...buildHeaders(token),
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
},
|
|
614
|
+
body: JSON.stringify({ runtimeData }),
|
|
615
|
+
},
|
|
616
|
+
args.json,
|
|
617
|
+
"deployment-finalize",
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
if (!args.wait) {
|
|
621
|
+
ok(
|
|
622
|
+
{
|
|
623
|
+
ok: true,
|
|
624
|
+
deploymentId: init.deploymentId,
|
|
625
|
+
status: "publishing",
|
|
626
|
+
liveUrl: init.liveUrl ?? portalUrl,
|
|
627
|
+
inspectorUrl: null,
|
|
628
|
+
portalCount,
|
|
629
|
+
firstPortalSlug,
|
|
630
|
+
fileSyncCount,
|
|
631
|
+
verification: {
|
|
632
|
+
portalStatus: null,
|
|
633
|
+
healthStatus: null,
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
args.json,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!args.wait) {
|
|
642
|
+
ok(
|
|
643
|
+
{
|
|
644
|
+
ok: true,
|
|
645
|
+
deploymentId: init.deploymentId,
|
|
646
|
+
status: init.status,
|
|
647
|
+
liveUrl: init.liveUrl ?? portalUrl,
|
|
648
|
+
inspectorUrl: null,
|
|
649
|
+
portalCount,
|
|
650
|
+
firstPortalSlug,
|
|
651
|
+
fileSyncCount,
|
|
652
|
+
verification: {
|
|
653
|
+
portalStatus: null,
|
|
654
|
+
healthStatus: null,
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
args.json,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
out("Waiting for deployment to go live", args.json);
|
|
662
|
+
let finalStatus: DeploymentStatusResponse | null = null;
|
|
663
|
+
const waitStart = Date.now();
|
|
664
|
+
let consecutiveStatusErrors = 0;
|
|
665
|
+
while (true) {
|
|
666
|
+
const rawStatusResponse = await fetchJson<DeploymentStatusResponse>(
|
|
667
|
+
`${apiBase}/api/deployments/${init.deploymentId}`,
|
|
668
|
+
{
|
|
669
|
+
headers: buildHeaders(token),
|
|
670
|
+
},
|
|
671
|
+
args.json,
|
|
672
|
+
"deployment-status",
|
|
673
|
+
);
|
|
674
|
+
const statusResponse = normalizeDeploymentStatus(rawStatusResponse);
|
|
675
|
+
|
|
676
|
+
finalStatus = statusResponse;
|
|
677
|
+
if (statusResponse.error) {
|
|
678
|
+
consecutiveStatusErrors += 1;
|
|
679
|
+
} else {
|
|
680
|
+
consecutiveStatusErrors = 0;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (consecutiveStatusErrors >= MAX_STATUS_ERROR_RETRIES) {
|
|
684
|
+
fail(
|
|
685
|
+
"deployment-status",
|
|
686
|
+
`Deployment status failed repeatedly while still ${statusResponse.status}`,
|
|
687
|
+
args.json,
|
|
688
|
+
statusResponse,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (statusResponse.terminal) {
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (Date.now() - waitStart > MAX_WAIT_MS) {
|
|
697
|
+
fail(
|
|
698
|
+
"deployment-status",
|
|
699
|
+
"Deployment did not reach a terminal state within 5 minutes",
|
|
700
|
+
args.json,
|
|
701
|
+
statusResponse,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const delay = statusResponse.pollAfterMs ?? 5_000;
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!finalStatus) {
|
|
710
|
+
fail("deployment-status", "No deployment status returned", args.json);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (finalStatus.status !== "live") {
|
|
714
|
+
fail(
|
|
715
|
+
"deployment-status",
|
|
716
|
+
`Deployment ended in ${finalStatus.status}`,
|
|
717
|
+
args.json,
|
|
718
|
+
finalStatus.error ?? finalStatus,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const liveUrl = finalStatus.liveUrl ?? init.liveUrl ?? portalUrl;
|
|
723
|
+
const portalStatus = liveUrl && firstPortalSlug
|
|
724
|
+
? await verifyUrl(
|
|
725
|
+
`${liveUrl.replace(/\/$/, "")}/client/${firstPortalSlug}`,
|
|
726
|
+
[200, 307, 401, 403],
|
|
727
|
+
)
|
|
728
|
+
: null;
|
|
729
|
+
const healthStatus = liveUrl
|
|
730
|
+
? await verifyUrl(
|
|
731
|
+
`${liveUrl.replace(/\/$/, "")}/api/health`,
|
|
732
|
+
[200],
|
|
733
|
+
)
|
|
734
|
+
: null;
|
|
735
|
+
|
|
736
|
+
ok(
|
|
737
|
+
{
|
|
738
|
+
ok: true,
|
|
739
|
+
deploymentId: finalStatus.deploymentId,
|
|
740
|
+
status: finalStatus.status,
|
|
741
|
+
liveUrl,
|
|
742
|
+
inspectorUrl: finalStatus.inspectorUrl,
|
|
743
|
+
portalCount,
|
|
744
|
+
firstPortalSlug,
|
|
745
|
+
fileSyncCount,
|
|
746
|
+
verification: {
|
|
747
|
+
portalStatus,
|
|
748
|
+
healthStatus,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
args.json,
|
|
752
|
+
);
|
|
753
|
+
} finally {
|
|
754
|
+
fs.rmSync(artifactPath, { force: true });
|
|
755
|
+
buildWorkspace.cleanup();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
main().catch((error) => {
|
|
760
|
+
fail("deploy", error instanceof Error ? error.message : String(error), process.argv.includes("--json"));
|
|
761
|
+
});
|