@freecodecamp/universe-cli 0.3.2 → 0.4.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 +88 -19
- package/dist/index.cjs +16060 -47360
- package/dist/index.js +1422 -792
- package/package.json +7 -10
package/dist/index.js
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { cac } from "cac";
|
|
5
5
|
|
|
6
|
+
// src/commands/deploy.ts
|
|
7
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
8
|
+
import { resolve as resolve2 } from "path";
|
|
9
|
+
import { log } from "@clack/prompts";
|
|
10
|
+
|
|
6
11
|
// src/output/exit-codes.ts
|
|
7
12
|
var EXIT_USAGE = 10;
|
|
8
13
|
var EXIT_CONFIG = 11;
|
|
9
|
-
var
|
|
14
|
+
var EXIT_CREDENTIALS = 12;
|
|
10
15
|
var EXIT_STORAGE = 13;
|
|
11
|
-
var EXIT_OUTPUT_DIR = 14;
|
|
12
16
|
var EXIT_GIT = 15;
|
|
13
|
-
var EXIT_ALIAS = 16;
|
|
14
|
-
var EXIT_DEPLOY_NOT_FOUND = 17;
|
|
15
17
|
var EXIT_CONFIRM = 18;
|
|
16
18
|
var EXIT_PARTIAL = 19;
|
|
17
19
|
function exitWithCode(code, message) {
|
|
@@ -32,7 +34,7 @@ var ConfigError = class extends CliError {
|
|
|
32
34
|
exitCode = EXIT_CONFIG;
|
|
33
35
|
};
|
|
34
36
|
var CredentialError = class extends CliError {
|
|
35
|
-
exitCode =
|
|
37
|
+
exitCode = EXIT_CREDENTIALS;
|
|
36
38
|
};
|
|
37
39
|
var StorageError = class extends CliError {
|
|
38
40
|
exitCode = EXIT_STORAGE;
|
|
@@ -40,440 +42,35 @@ var StorageError = class extends CliError {
|
|
|
40
42
|
var GitError = class extends CliError {
|
|
41
43
|
exitCode = EXIT_GIT;
|
|
42
44
|
};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// src/config/schema.ts
|
|
50
|
-
import { z } from "zod";
|
|
51
|
-
var staticSchema = z.object({
|
|
52
|
-
output_dir: z.string().min(1).default("dist"),
|
|
53
|
-
bucket: z.string().min(1).default("gxy-static-1"),
|
|
54
|
-
rclone_remote: z.string().min(1).default("gxy-static"),
|
|
55
|
-
region: z.string().min(1).default("auto")
|
|
56
|
-
}).prefault({});
|
|
57
|
-
var domainSchema = z.object({
|
|
58
|
-
production: z.string().min(1),
|
|
59
|
-
preview: z.string().min(1)
|
|
60
|
-
});
|
|
61
|
-
var platformSchema = z.object({
|
|
62
|
-
name: z.string().min(1).regex(/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/i),
|
|
63
|
-
stack: z.literal("static"),
|
|
64
|
-
domain: domainSchema,
|
|
65
|
-
static: staticSchema
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
// src/config/loader.ts
|
|
69
|
-
function assertSafeOutputDir(outputDir, cwd) {
|
|
70
|
-
if (isAbsolute(outputDir)) {
|
|
71
|
-
throw new ConfigError(
|
|
72
|
-
`output_dir must be relative to the project root; absolute paths are rejected: ${outputDir}`
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
const resolved = resolve(cwd, outputDir);
|
|
76
|
-
const rel = relative(cwd, resolved);
|
|
77
|
-
if (rel === "..") {
|
|
78
|
-
throw new ConfigError(
|
|
79
|
-
`output_dir resolves outside the project root: ${outputDir}`
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
if (rel.startsWith(`..${sep}`)) {
|
|
83
|
-
throw new ConfigError(
|
|
84
|
-
`output_dir resolves outside the project root: ${outputDir}`
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
function readEnvOverrides() {
|
|
89
|
-
const overrides = {};
|
|
90
|
-
if (process.env.UNIVERSE_STATIC_OUTPUT_DIR) {
|
|
91
|
-
overrides.output_dir = process.env.UNIVERSE_STATIC_OUTPUT_DIR;
|
|
92
|
-
}
|
|
93
|
-
if (process.env.UNIVERSE_STATIC_BUCKET) {
|
|
94
|
-
overrides.bucket = process.env.UNIVERSE_STATIC_BUCKET;
|
|
95
|
-
}
|
|
96
|
-
if (process.env.UNIVERSE_STATIC_RCLONE_REMOTE) {
|
|
97
|
-
overrides.rclone_remote = process.env.UNIVERSE_STATIC_RCLONE_REMOTE;
|
|
98
|
-
}
|
|
99
|
-
if (process.env.UNIVERSE_STATIC_REGION) {
|
|
100
|
-
overrides.region = process.env.UNIVERSE_STATIC_REGION;
|
|
101
|
-
}
|
|
102
|
-
return overrides;
|
|
103
|
-
}
|
|
104
|
-
function readFlagOverrides(flags) {
|
|
105
|
-
if (!flags) return {};
|
|
106
|
-
const overrides = {};
|
|
107
|
-
if (flags.outputDir) overrides.output_dir = flags.outputDir;
|
|
108
|
-
if (flags.bucket) overrides.bucket = flags.bucket;
|
|
109
|
-
if (flags.rcloneRemote) overrides.rclone_remote = flags.rcloneRemote;
|
|
110
|
-
if (flags.region) overrides.region = flags.region;
|
|
111
|
-
return overrides;
|
|
112
|
-
}
|
|
113
|
-
function loadConfig(options = {}) {
|
|
114
|
-
const cwd = options.cwd ?? process.cwd();
|
|
115
|
-
const configPath = resolve(cwd, "platform.yaml");
|
|
116
|
-
let raw;
|
|
117
|
-
try {
|
|
118
|
-
raw = readFileSync(configPath, "utf-8");
|
|
119
|
-
} catch (err) {
|
|
120
|
-
if (err instanceof Error && err.code === "ENOENT") {
|
|
121
|
-
throw new ConfigError(
|
|
122
|
-
`platform.yaml not found at ${configPath}. See docs/STAFF-GUIDE.md for the required format.`
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
127
|
-
const parsed = parseYaml(raw);
|
|
128
|
-
const parseResult = platformSchema.safeParse(parsed);
|
|
129
|
-
if (!parseResult.success) {
|
|
130
|
-
const issues = parseResult.error.issues.map((issue) => {
|
|
131
|
-
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
132
|
-
return ` - ${path}: ${issue.message}`;
|
|
133
|
-
}).join("\n");
|
|
134
|
-
throw new ConfigError(
|
|
135
|
-
`platform.yaml is invalid:
|
|
136
|
-
${issues}
|
|
137
|
-
See docs/STAFF-GUIDE.md for the required format.`
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
const yamlValidated = parseResult.data;
|
|
141
|
-
const envOverrides = readEnvOverrides();
|
|
142
|
-
const flagOverrides = readFlagOverrides(options.flags);
|
|
143
|
-
const merged = {
|
|
144
|
-
...yamlValidated,
|
|
145
|
-
static: {
|
|
146
|
-
...yamlValidated.static,
|
|
147
|
-
...envOverrides,
|
|
148
|
-
...flagOverrides
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
assertSafeOutputDir(merged.static.output_dir, cwd);
|
|
152
|
-
return merged;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// src/credentials/resolver.ts
|
|
156
|
-
import { execSync } from "child_process";
|
|
157
|
-
|
|
158
|
-
// src/output/redact.ts
|
|
159
|
-
var AWS_KEY_PREFIX_PATTERN = /(?:AKIA|ASIA|AROA|AIDA|ACCA|ANPA|ABIA|AGPA)[A-Z0-9]{12,}/g;
|
|
160
|
-
var URL_CREDS_PATTERN = /https?:\/\/[^@\s]+@/g;
|
|
161
|
-
var CREDENTIAL_CONTEXT_PATTERN = /(?:access_key_id|secret_access_key|accessKeyId|secretAccessKey|secret|password|token|key|credential|auth)\s*[=:]\s*([A-Za-z0-9/+=]{21,})/gi;
|
|
162
|
-
var HEX_CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key)\s*[=:]\s*([a-f0-9]{32,})/gi;
|
|
163
|
-
var JSON_CREDENTIAL_PATTERN = /"(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key|accessKeyId|secretAccessKey)"\s*:\s*"[^"]+"/gi;
|
|
164
|
-
var BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
165
|
-
function maskAwsKey(match) {
|
|
166
|
-
return match.slice(0, 4) + "****" + match.slice(-4);
|
|
167
|
-
}
|
|
168
|
-
function maskUrlCreds(match) {
|
|
169
|
-
const atIndex = match.lastIndexOf("@");
|
|
170
|
-
const protocolEnd = match.indexOf("://") + 3;
|
|
171
|
-
return match.slice(0, protocolEnd) + "****:****@" + match.slice(atIndex + 1);
|
|
172
|
-
}
|
|
173
|
-
function redact(value) {
|
|
174
|
-
let result = value;
|
|
175
|
-
result = result.replace(URL_CREDS_PATTERN, maskUrlCreds);
|
|
176
|
-
result = result.replace(AWS_KEY_PREFIX_PATTERN, maskAwsKey);
|
|
177
|
-
result = result.replace(BEARER_PATTERN, "Bearer ****");
|
|
178
|
-
result = result.replace(JSON_CREDENTIAL_PATTERN, (match) => {
|
|
179
|
-
const colonIndex = match.indexOf(":");
|
|
180
|
-
return match.slice(0, colonIndex + 1) + '"****"';
|
|
181
|
-
});
|
|
182
|
-
result = result.replace(
|
|
183
|
-
CREDENTIAL_CONTEXT_PATTERN,
|
|
184
|
-
(_match, _secret, _offset, _full) => {
|
|
185
|
-
const eqIndex = _match.indexOf("=");
|
|
186
|
-
const colonIndex = _match.indexOf(":");
|
|
187
|
-
const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
|
|
188
|
-
const prefix = _match.slice(0, sepIndex + 1);
|
|
189
|
-
return prefix + "****";
|
|
190
|
-
}
|
|
191
|
-
);
|
|
192
|
-
result = result.replace(HEX_CREDENTIAL_CONTEXT_PATTERN, (_match, _secret) => {
|
|
193
|
-
const eqIndex = _match.indexOf("=");
|
|
194
|
-
const colonIndex = _match.indexOf(":");
|
|
195
|
-
const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
|
|
196
|
-
const prefix = _match.slice(0, sepIndex + 1);
|
|
197
|
-
return prefix + "****";
|
|
198
|
-
});
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// src/credentials/resolver.ts
|
|
203
|
-
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
204
|
-
function validateEndpoint(endpoint) {
|
|
205
|
-
let url;
|
|
206
|
-
try {
|
|
207
|
-
url = new URL(endpoint);
|
|
208
|
-
} catch {
|
|
209
|
-
throw new CredentialError(`S3_ENDPOINT is not a valid URL: ${endpoint}`);
|
|
210
|
-
}
|
|
211
|
-
if (url.username !== "" || url.password !== "") {
|
|
212
|
-
throw new CredentialError(
|
|
213
|
-
"S3_ENDPOINT must not contain credentials in the URL (user:pass@host). Use S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY instead."
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
217
|
-
throw new CredentialError(
|
|
218
|
-
`S3_ENDPOINT must use http or https, got: ${url.protocol}`
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
if (url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname)) {
|
|
222
|
-
throw new CredentialError(
|
|
223
|
-
`S3_ENDPOINT must use https for non-localhost hosts. Plaintext http is only allowed for localhost/127.0.0.1. Got: ${endpoint}`
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
function tryEnvCredentials() {
|
|
228
|
-
const key = process.env.S3_ACCESS_KEY_ID;
|
|
229
|
-
const secret = process.env.S3_SECRET_ACCESS_KEY;
|
|
230
|
-
const endpoint = process.env.S3_ENDPOINT;
|
|
231
|
-
const region = process.env.S3_REGION;
|
|
232
|
-
const present = [key, secret, endpoint].filter(Boolean);
|
|
233
|
-
if (present.length === 0) return "none";
|
|
234
|
-
if (present.length < 3) return "partial";
|
|
235
|
-
validateEndpoint(endpoint);
|
|
236
|
-
const creds = {
|
|
237
|
-
accessKeyId: key,
|
|
238
|
-
secretAccessKey: secret,
|
|
239
|
-
endpoint
|
|
240
|
-
};
|
|
241
|
-
if (region) creds.region = region;
|
|
242
|
-
return creds;
|
|
243
|
-
}
|
|
244
|
-
function fromRclone(remoteName) {
|
|
245
|
-
let output;
|
|
246
|
-
try {
|
|
247
|
-
output = execSync("rclone config dump", { stdio: "pipe" });
|
|
248
|
-
} catch (err) {
|
|
249
|
-
if (err instanceof Error && err.code === "ENOENT") {
|
|
250
|
-
throw new CredentialError(
|
|
251
|
-
"rclone not found \u2014 install rclone or provide S3 credentials via environment variables"
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
throw new CredentialError(
|
|
255
|
-
`Failed to run rclone config dump: ${redact(err instanceof Error ? err.message : "unknown error")}`
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
let parsed;
|
|
259
|
-
try {
|
|
260
|
-
parsed = JSON.parse(output.toString("utf-8"));
|
|
261
|
-
} catch {
|
|
262
|
-
throw new CredentialError(
|
|
263
|
-
`Failed to parse rclone config for remote ${remoteName}`
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
output = null;
|
|
267
|
-
const remote = parsed[remoteName];
|
|
268
|
-
if (!remote) {
|
|
269
|
-
throw new CredentialError(
|
|
270
|
-
`Remote "${remoteName}" not found in rclone config. Available remotes: ${Object.keys(parsed).join(", ") || "(none)"}`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
const missing = [];
|
|
274
|
-
if (!remote.access_key_id) missing.push("access_key_id");
|
|
275
|
-
if (!remote.secret_access_key) missing.push("secret_access_key");
|
|
276
|
-
if (!remote.endpoint) missing.push("endpoint");
|
|
277
|
-
if (missing.length > 0) {
|
|
278
|
-
throw new CredentialError(
|
|
279
|
-
`Rclone remote "${remoteName}" is missing required fields: ${missing.join(", ")}`
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
const creds = {
|
|
283
|
-
accessKeyId: remote.access_key_id,
|
|
284
|
-
secretAccessKey: remote.secret_access_key,
|
|
285
|
-
endpoint: remote.endpoint
|
|
286
|
-
};
|
|
287
|
-
if (remote.region) creds.region = remote.region;
|
|
288
|
-
return creds;
|
|
289
|
-
}
|
|
290
|
-
function resolveCredentials(options) {
|
|
291
|
-
const envResult = tryEnvCredentials();
|
|
292
|
-
if (envResult === "partial") {
|
|
293
|
-
throw new CredentialError(
|
|
294
|
-
"Partial S3 credentials in environment. Set all three: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_ENDPOINT \u2014 or remove all to use rclone fallback."
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
if (envResult !== "none") {
|
|
298
|
-
return envResult;
|
|
299
|
-
}
|
|
300
|
-
return fromRclone(options.remoteName);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// src/storage/client.ts
|
|
304
|
-
import { S3Client } from "@aws-sdk/client-s3";
|
|
305
|
-
function createS3Client(credentials) {
|
|
306
|
-
return new S3Client({
|
|
307
|
-
endpoint: credentials.endpoint,
|
|
308
|
-
region: credentials.region ?? "auto",
|
|
309
|
-
credentials: {
|
|
310
|
-
accessKeyId: credentials.accessKeyId,
|
|
311
|
-
secretAccessKey: credentials.secretAccessKey
|
|
312
|
-
},
|
|
313
|
-
forcePathStyle: true
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// src/storage/operations.ts
|
|
318
|
-
import {
|
|
319
|
-
PutObjectCommand,
|
|
320
|
-
GetObjectCommand,
|
|
321
|
-
ListObjectsV2Command,
|
|
322
|
-
HeadObjectCommand,
|
|
323
|
-
DeleteObjectCommand,
|
|
324
|
-
DeleteObjectsCommand
|
|
325
|
-
} from "@aws-sdk/client-s3";
|
|
326
|
-
var MAX_RETRIES = 3;
|
|
327
|
-
var BASE_DELAY_MS = 500;
|
|
328
|
-
function isTransientError(error) {
|
|
329
|
-
if (!(error instanceof Error)) return false;
|
|
330
|
-
const meta = error.$metadata;
|
|
331
|
-
const statusCode = meta?.httpStatusCode;
|
|
332
|
-
if (statusCode !== void 0 && statusCode >= 500) return true;
|
|
333
|
-
if (statusCode === 429) return true;
|
|
334
|
-
const name = error.name;
|
|
335
|
-
if (name === "ThrottlingException" || name === "TooManyRequestsException" || name === "RequestTimeout" || name === "RequestTimeoutException")
|
|
336
|
-
return true;
|
|
337
|
-
if (name === "NetworkingError" || name === "TimeoutError" || error.code === "ECONNRESET")
|
|
338
|
-
return true;
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
async function withRetry(fn) {
|
|
342
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
343
|
-
try {
|
|
344
|
-
return await fn();
|
|
345
|
-
} catch (error) {
|
|
346
|
-
if (!isTransientError(error) || attempt === MAX_RETRIES) {
|
|
347
|
-
throw error;
|
|
348
|
-
}
|
|
349
|
-
const jitter = Math.random() * BASE_DELAY_MS;
|
|
350
|
-
const delay = BASE_DELAY_MS * Math.pow(2, attempt) + jitter;
|
|
351
|
-
await new Promise((resolve2) => setTimeout(resolve2, delay));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
throw new Error("withRetry: unreachable");
|
|
355
|
-
}
|
|
356
|
-
async function putObject(client, params) {
|
|
357
|
-
await withRetry(
|
|
358
|
-
() => client.send(
|
|
359
|
-
new PutObjectCommand({
|
|
360
|
-
Bucket: params.bucket,
|
|
361
|
-
Key: params.key,
|
|
362
|
-
Body: params.body,
|
|
363
|
-
ContentType: params.contentType,
|
|
364
|
-
CacheControl: params.cacheControl
|
|
365
|
-
})
|
|
366
|
-
)
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
async function getObject(client, params) {
|
|
370
|
-
try {
|
|
371
|
-
const response = await withRetry(
|
|
372
|
-
() => client.send(
|
|
373
|
-
new GetObjectCommand({
|
|
374
|
-
Bucket: params.bucket,
|
|
375
|
-
Key: params.key
|
|
376
|
-
})
|
|
377
|
-
)
|
|
378
|
-
);
|
|
379
|
-
return await response.Body?.transformToString() ?? null;
|
|
380
|
-
} catch (error) {
|
|
381
|
-
if (error instanceof Error && error.name === "NoSuchKey") {
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
throw error;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
async function listObjects(client, params) {
|
|
388
|
-
const allItems = [];
|
|
389
|
-
let continuationToken;
|
|
390
|
-
do {
|
|
391
|
-
const response = await withRetry(
|
|
392
|
-
() => client.send(
|
|
393
|
-
new ListObjectsV2Command({
|
|
394
|
-
Bucket: params.bucket,
|
|
395
|
-
Prefix: params.prefix,
|
|
396
|
-
ContinuationToken: continuationToken
|
|
397
|
-
})
|
|
398
|
-
)
|
|
399
|
-
);
|
|
400
|
-
if (response.Contents) {
|
|
401
|
-
for (const item of response.Contents) {
|
|
402
|
-
allItems.push({
|
|
403
|
-
key: item.Key ?? "",
|
|
404
|
-
size: item.Size ?? 0,
|
|
405
|
-
lastModified: item.LastModified ?? /* @__PURE__ */ new Date(0)
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
410
|
-
} while (continuationToken);
|
|
411
|
-
return allItems;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// src/storage/aliases.ts
|
|
415
|
-
async function readAlias(client, bucket, site, aliasName) {
|
|
416
|
-
const result = await getObject(client, {
|
|
417
|
-
bucket,
|
|
418
|
-
key: `${site}/${aliasName}`
|
|
419
|
-
});
|
|
420
|
-
return result !== null ? result.trim() : null;
|
|
421
|
-
}
|
|
422
|
-
async function writeAlias(client, bucket, site, aliasName, deployId) {
|
|
423
|
-
await putObject(client, {
|
|
424
|
-
bucket,
|
|
425
|
-
key: `${site}/${aliasName}`,
|
|
426
|
-
body: deployId,
|
|
427
|
-
contentType: "text/plain"
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// src/deploy/id.ts
|
|
432
|
-
function generateDeployId(gitHash, force = false) {
|
|
433
|
-
if (gitHash === void 0) {
|
|
434
|
-
if (!force) {
|
|
435
|
-
throw new GitError("git hash is required unless --force is set");
|
|
436
|
-
}
|
|
437
|
-
return formatId("nogit");
|
|
438
|
-
}
|
|
439
|
-
return formatId(gitHash.slice(0, 7));
|
|
440
|
-
}
|
|
441
|
-
function formatId(suffix) {
|
|
442
|
-
const now = /* @__PURE__ */ new Date();
|
|
443
|
-
const y = now.getUTCFullYear();
|
|
444
|
-
const mo = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
445
|
-
const d = String(now.getUTCDate()).padStart(2, "0");
|
|
446
|
-
const h = String(now.getUTCHours()).padStart(2, "0");
|
|
447
|
-
const mi = String(now.getUTCMinutes()).padStart(2, "0");
|
|
448
|
-
const s = String(now.getUTCSeconds()).padStart(2, "0");
|
|
449
|
-
return `${y}${mo}${d}-${h}${mi}${s}-${suffix}`;
|
|
450
|
-
}
|
|
45
|
+
var PartialUploadError = class extends CliError {
|
|
46
|
+
exitCode = EXIT_PARTIAL;
|
|
47
|
+
};
|
|
48
|
+
var UsageError = class extends CliError {
|
|
49
|
+
exitCode = EXIT_USAGE;
|
|
50
|
+
};
|
|
451
51
|
|
|
452
52
|
// src/deploy/git.ts
|
|
453
|
-
import { execSync
|
|
53
|
+
import { execSync } from "child_process";
|
|
454
54
|
function getGitState() {
|
|
455
55
|
try {
|
|
456
|
-
const hash =
|
|
457
|
-
const status =
|
|
56
|
+
const hash = execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
|
|
57
|
+
const status = execSync("git status --porcelain", { encoding: "utf-8" });
|
|
458
58
|
return { hash, dirty: status.length > 0 };
|
|
459
59
|
} catch {
|
|
460
60
|
return { hash: null, dirty: false, error: "not a git repository" };
|
|
461
61
|
}
|
|
462
62
|
}
|
|
463
63
|
|
|
464
|
-
// src/deploy/preflight.ts
|
|
465
|
-
import { existsSync, statSync as statSync2 } from "fs";
|
|
466
|
-
|
|
467
64
|
// src/deploy/walk.ts
|
|
468
65
|
import { readdirSync, realpathSync, statSync } from "fs";
|
|
469
|
-
import { join, relative
|
|
66
|
+
import { join, relative, sep } from "path";
|
|
470
67
|
function walkFiles(base) {
|
|
471
68
|
const baseReal = realpathSync(base);
|
|
472
69
|
const entries = readdirSync(base, { recursive: true, withFileTypes: true });
|
|
473
70
|
const files = [];
|
|
474
71
|
for (const entry of entries) {
|
|
475
72
|
const full = join(entry.parentPath, entry.name);
|
|
476
|
-
const relPath =
|
|
73
|
+
const relPath = relative(base, full);
|
|
477
74
|
let targetIsFile;
|
|
478
75
|
try {
|
|
479
76
|
targetIsFile = statSync(full).isFile();
|
|
@@ -491,8 +88,8 @@ function walkFiles(base) {
|
|
|
491
88
|
`"${relPath}" could not be resolved (dangling symlink?)`
|
|
492
89
|
);
|
|
493
90
|
}
|
|
494
|
-
const rel =
|
|
495
|
-
if (rel === ".." || rel.startsWith(`..${
|
|
91
|
+
const rel = relative(baseReal, resolved);
|
|
92
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
496
93
|
throw new StorageError(
|
|
497
94
|
`"${relPath}" resolves outside the output directory (symlink escape). Resolved to: ${resolved}`
|
|
498
95
|
);
|
|
@@ -502,421 +99,1405 @@ function walkFiles(base) {
|
|
|
502
99
|
return files;
|
|
503
100
|
}
|
|
504
101
|
|
|
505
|
-
// src/
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
102
|
+
// src/lib/build.ts
|
|
103
|
+
import { spawn } from "child_process";
|
|
104
|
+
import { stat } from "fs/promises";
|
|
105
|
+
import { isAbsolute, resolve } from "path";
|
|
106
|
+
var defaultExec = async (req) => {
|
|
107
|
+
return new Promise((resolveExit, reject) => {
|
|
108
|
+
const child = spawn(req.command, {
|
|
109
|
+
cwd: req.cwd,
|
|
110
|
+
shell: true,
|
|
111
|
+
stdio: "inherit"
|
|
112
|
+
});
|
|
113
|
+
child.on("error", (err) => reject(err));
|
|
114
|
+
child.on("exit", (code) => resolveExit(code ?? 1));
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
async function ensureDirectory(absPath) {
|
|
118
|
+
let st;
|
|
119
|
+
try {
|
|
120
|
+
st = await stat(absPath);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new ConfigError(`output directory missing after build: ${absPath}`);
|
|
123
|
+
}
|
|
124
|
+
if (!st.isDirectory()) {
|
|
125
|
+
throw new ConfigError(
|
|
126
|
+
`output path is not a directory after build: ${absPath}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function runBuild(options, deps = {}) {
|
|
131
|
+
const exec = deps.exec ?? defaultExec;
|
|
132
|
+
const absCwd = isAbsolute(options.cwd) ? options.cwd : resolve(options.cwd);
|
|
133
|
+
const absOutput = isAbsolute(options.outputDir) ? options.outputDir : resolve(absCwd, options.outputDir);
|
|
134
|
+
if (!options.command) {
|
|
135
|
+
await ensureDirectory(absOutput);
|
|
136
|
+
return { skipped: true, outputDir: absOutput };
|
|
137
|
+
}
|
|
138
|
+
const code = await exec({ command: options.command, cwd: absCwd });
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
throw new ConfigError(
|
|
141
|
+
`build command failed with exit code ${code}: ${options.command}`
|
|
142
|
+
);
|
|
509
143
|
}
|
|
510
|
-
|
|
511
|
-
|
|
144
|
+
await ensureDirectory(absOutput);
|
|
145
|
+
return { skipped: false, outputDir: absOutput };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/lib/constants.ts
|
|
149
|
+
var DEFAULT_GH_CLIENT_ID = "Iv23liIuGmZRyPd5wUeN";
|
|
150
|
+
var DEFAULT_PROXY_URL = "https://uploads.freecode.camp";
|
|
151
|
+
|
|
152
|
+
// src/lib/identity.ts
|
|
153
|
+
import { execFile } from "child_process";
|
|
154
|
+
import { promisify } from "util";
|
|
155
|
+
|
|
156
|
+
// src/lib/token-store.ts
|
|
157
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
158
|
+
import { homedir } from "os";
|
|
159
|
+
import { dirname, join as join2 } from "path";
|
|
160
|
+
var APP_DIR = "universe-cli";
|
|
161
|
+
var TOKEN_FILE = "token";
|
|
162
|
+
function configBase() {
|
|
163
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
164
|
+
if (xdg && xdg.length > 0) return xdg;
|
|
165
|
+
return join2(homedir(), ".config");
|
|
166
|
+
}
|
|
167
|
+
function tokenPath() {
|
|
168
|
+
return join2(configBase(), APP_DIR, TOKEN_FILE);
|
|
169
|
+
}
|
|
170
|
+
async function saveToken(token) {
|
|
171
|
+
const trimmed = token.trim();
|
|
172
|
+
if (trimmed.length === 0) {
|
|
173
|
+
throw new Error("refusing to save empty token");
|
|
512
174
|
}
|
|
513
|
-
|
|
175
|
+
const path = tokenPath();
|
|
176
|
+
const dir = dirname(path);
|
|
177
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
178
|
+
await chmod(dir, 448);
|
|
179
|
+
await writeFile(path, trimmed, { mode: 384 });
|
|
180
|
+
await chmod(path, 384);
|
|
181
|
+
}
|
|
182
|
+
async function loadToken() {
|
|
183
|
+
let raw;
|
|
514
184
|
try {
|
|
515
|
-
|
|
185
|
+
raw = await readFile(tokenPath(), "utf-8");
|
|
516
186
|
} catch (err) {
|
|
517
|
-
if (err
|
|
518
|
-
return { valid: false, fileCount: 0, error: err.message };
|
|
519
|
-
}
|
|
187
|
+
if (isFileNotFound(err)) return null;
|
|
520
188
|
throw err;
|
|
521
189
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
190
|
+
const trimmed = raw.trim();
|
|
191
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
192
|
+
}
|
|
193
|
+
async function deleteToken() {
|
|
194
|
+
await rm(tokenPath(), { force: true });
|
|
195
|
+
}
|
|
196
|
+
function isFileNotFound(err) {
|
|
197
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
526
198
|
}
|
|
527
199
|
|
|
528
|
-
// src/
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
return
|
|
200
|
+
// src/lib/identity.ts
|
|
201
|
+
var execFileP = promisify(execFile);
|
|
202
|
+
function isNonEmpty(s) {
|
|
203
|
+
return typeof s === "string" && s.trim().length > 0;
|
|
204
|
+
}
|
|
205
|
+
async function defaultExecGhAuthToken() {
|
|
206
|
+
try {
|
|
207
|
+
const { stdout } = await execFileP("gh", ["auth", "token"], {
|
|
208
|
+
timeout: 5e3
|
|
209
|
+
});
|
|
210
|
+
return stdout;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
539
213
|
}
|
|
540
|
-
return mime;
|
|
541
214
|
}
|
|
542
|
-
function
|
|
543
|
-
|
|
544
|
-
|
|
215
|
+
async function resolveIdentity(opts = {}) {
|
|
216
|
+
const env = opts.env ?? process.env;
|
|
217
|
+
const execGh = opts.execGhAuthToken ?? defaultExecGhAuthToken;
|
|
218
|
+
const loadStored = opts.loadStoredToken ?? loadToken;
|
|
219
|
+
const ghEnv = env["GITHUB_TOKEN"];
|
|
220
|
+
if (isNonEmpty(ghEnv)) {
|
|
221
|
+
return { token: ghEnv.trim(), source: "env_GITHUB_TOKEN" };
|
|
222
|
+
}
|
|
223
|
+
const ghTokenEnv = env["GH_TOKEN"];
|
|
224
|
+
if (isNonEmpty(ghTokenEnv)) {
|
|
225
|
+
return { token: ghTokenEnv.trim(), source: "env_GH_TOKEN" };
|
|
226
|
+
}
|
|
227
|
+
const ghCli = await execGh();
|
|
228
|
+
if (isNonEmpty(ghCli)) {
|
|
229
|
+
return { token: ghCli.trim(), source: "gh_cli" };
|
|
545
230
|
}
|
|
546
|
-
|
|
547
|
-
|
|
231
|
+
const stored = await loadStored();
|
|
232
|
+
if (isNonEmpty(stored)) {
|
|
233
|
+
return { token: stored.trim(), source: "device_flow" };
|
|
548
234
|
}
|
|
549
|
-
return
|
|
235
|
+
return null;
|
|
550
236
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
let
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
(
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
totalSize += body.byteLength;
|
|
566
|
-
fileCount++;
|
|
567
|
-
await putObject(client, {
|
|
568
|
-
bucket,
|
|
569
|
-
key,
|
|
570
|
-
body,
|
|
571
|
-
contentType,
|
|
572
|
-
cacheControl
|
|
573
|
-
});
|
|
574
|
-
} catch (err) {
|
|
575
|
-
const message = err instanceof Error ? err.message : "unknown upload error";
|
|
576
|
-
errors.push(`${file.relPath}: ${message}`);
|
|
237
|
+
|
|
238
|
+
// src/lib/ignore.ts
|
|
239
|
+
var SPECIAL = /[.+^${}()|[\]\\]/g;
|
|
240
|
+
function globToRegexBody(pattern) {
|
|
241
|
+
let out = "";
|
|
242
|
+
let i = 0;
|
|
243
|
+
while (i < pattern.length) {
|
|
244
|
+
const ch = pattern[i] ?? "";
|
|
245
|
+
if (ch === "*") {
|
|
246
|
+
const next = pattern[i + 1];
|
|
247
|
+
if (next === "*") {
|
|
248
|
+
out += ".*";
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
577
251
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
body,
|
|
592
|
-
contentType: "application/json"
|
|
593
|
-
});
|
|
252
|
+
out += "[^/]*";
|
|
253
|
+
i += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (ch === "?") {
|
|
257
|
+
out += "[^/]";
|
|
258
|
+
i += 1;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
out += ch.replace(SPECIAL, "\\$&");
|
|
262
|
+
i += 1;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
594
265
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
266
|
+
function compilePattern(pattern) {
|
|
267
|
+
const anchored = pattern.includes("/");
|
|
268
|
+
const body = globToRegexBody(pattern);
|
|
269
|
+
if (anchored) {
|
|
270
|
+
const re2 = new RegExp(`^${body}$`);
|
|
271
|
+
return { test: (rel) => re2.test(rel) };
|
|
272
|
+
}
|
|
273
|
+
const re = new RegExp(`^${body}$`);
|
|
601
274
|
return {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
275
|
+
test: (rel) => {
|
|
276
|
+
const idx = rel.lastIndexOf("/");
|
|
277
|
+
const base = idx === -1 ? rel : rel.slice(idx + 1);
|
|
278
|
+
return re.test(base);
|
|
279
|
+
}
|
|
607
280
|
};
|
|
608
281
|
}
|
|
609
|
-
function
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
error
|
|
282
|
+
function normalize(rel) {
|
|
283
|
+
let out = rel.replace(/\\/g, "/");
|
|
284
|
+
if (out.startsWith("./")) out = out.slice(2);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
function createIgnoreFilter(patterns) {
|
|
288
|
+
const compiled = patterns.filter((p) => p.length > 0).map(compilePattern);
|
|
289
|
+
return (relPath) => {
|
|
290
|
+
const norm = normalize(relPath);
|
|
291
|
+
for (const c of compiled) {
|
|
292
|
+
if (c.test(norm)) return true;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
623
295
|
};
|
|
624
296
|
}
|
|
625
297
|
|
|
626
|
-
// src/
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
298
|
+
// src/lib/platform-yaml.ts
|
|
299
|
+
import { parse as parseYaml } from "yaml";
|
|
300
|
+
|
|
301
|
+
// src/lib/platform-yaml.schema.ts
|
|
302
|
+
import { z } from "zod";
|
|
303
|
+
var SITE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
304
|
+
var siteName = z.string().min(1, "site is required").max(63, "site must be at most 63 characters").regex(
|
|
305
|
+
SITE_NAME_PATTERN,
|
|
306
|
+
"site must be lowercase letters, digits, and single hyphens; no leading/trailing/consecutive hyphens"
|
|
307
|
+
);
|
|
308
|
+
var buildSchema = z.object({
|
|
309
|
+
command: z.string().min(1).optional(),
|
|
310
|
+
output: z.string().min(1).default("dist")
|
|
311
|
+
}).strict();
|
|
312
|
+
var DEFAULT_DEPLOY_IGNORE = Object.freeze([
|
|
313
|
+
"*.map",
|
|
314
|
+
"node_modules/**",
|
|
315
|
+
".git/**",
|
|
316
|
+
".env*"
|
|
317
|
+
]);
|
|
318
|
+
var deploySchema = z.object({
|
|
319
|
+
preview: z.boolean().default(true),
|
|
320
|
+
ignore: z.array(z.string()).default([...DEFAULT_DEPLOY_IGNORE])
|
|
321
|
+
}).strict().prefault({});
|
|
322
|
+
var platformYamlSchemaV2 = z.object({
|
|
323
|
+
site: siteName,
|
|
324
|
+
build: buildSchema.prefault({}),
|
|
325
|
+
deploy: deploySchema
|
|
326
|
+
}).strict();
|
|
327
|
+
|
|
328
|
+
// src/lib/platform-yaml.ts
|
|
329
|
+
var V1_MARKERS = ["r2", "stack", "domain", "static", "name"];
|
|
330
|
+
var MIGRATION_HINT = "platform.yaml v1 detected. v0.4 removes credential paths (r2, bucket, region) and per-site team declarations. See docs/platform-yaml.md for the v0.3 \u2192 v0.4 migration.";
|
|
331
|
+
function isPlainObject(value) {
|
|
332
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
333
|
+
}
|
|
334
|
+
function detectV1(parsed) {
|
|
335
|
+
for (const marker of V1_MARKERS) {
|
|
336
|
+
if (Object.prototype.hasOwnProperty.call(parsed, marker)) {
|
|
337
|
+
return marker;
|
|
338
|
+
}
|
|
633
339
|
}
|
|
340
|
+
return void 0;
|
|
634
341
|
}
|
|
635
|
-
function
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
);
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
342
|
+
function formatZodIssues(issues) {
|
|
343
|
+
return issues.map((issue) => {
|
|
344
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
345
|
+
return ` - ${path}: ${issue.message}`;
|
|
346
|
+
}).join("\n");
|
|
347
|
+
}
|
|
348
|
+
function parsePlatformYaml(text) {
|
|
349
|
+
let parsed;
|
|
350
|
+
try {
|
|
351
|
+
parsed = parseYaml(text);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
354
|
+
return { ok: false, error: `platform.yaml is not valid YAML: ${message}` };
|
|
355
|
+
}
|
|
356
|
+
if (parsed === null || parsed === void 0) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: "platform.yaml is empty. Required field: `site`. See docs/platform-yaml.md."
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (!isPlainObject(parsed)) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: "platform.yaml must be a YAML mapping at the root. See docs/platform-yaml.md."
|
|
366
|
+
};
|
|
648
367
|
}
|
|
368
|
+
const v1Marker = detectV1(parsed);
|
|
369
|
+
if (v1Marker) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
error: `${MIGRATION_HINT} (v1 marker detected: \`${v1Marker}\`)`
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const result = platformYamlSchemaV2.safeParse(parsed);
|
|
376
|
+
if (!result.success) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
error: `platform.yaml is invalid:
|
|
380
|
+
${formatZodIssues(result.error.issues)}
|
|
381
|
+
See docs/platform-yaml.md.`
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return { ok: true, value: result.data };
|
|
649
385
|
}
|
|
650
386
|
|
|
651
|
-
// src/
|
|
652
|
-
var
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
387
|
+
// src/lib/proxy-client.ts
|
|
388
|
+
var ProxyError = class extends CliError {
|
|
389
|
+
exitCode;
|
|
390
|
+
status;
|
|
391
|
+
code;
|
|
392
|
+
constructor(status, code, message) {
|
|
393
|
+
super(message);
|
|
394
|
+
this.status = status;
|
|
395
|
+
this.code = code;
|
|
396
|
+
this.exitCode = mapExitCode(status);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
function mapExitCode(status) {
|
|
400
|
+
if (status === 401 || status === 403) return EXIT_CREDENTIALS;
|
|
401
|
+
if (status === 422 || status === 0 || status >= 500) return EXIT_STORAGE;
|
|
402
|
+
return EXIT_USAGE;
|
|
403
|
+
}
|
|
404
|
+
function wrapProxyError(command, err) {
|
|
405
|
+
if (err instanceof ProxyError) {
|
|
406
|
+
return {
|
|
407
|
+
code: err.exitCode,
|
|
408
|
+
message: `${command} failed (${err.code}): ${err.message}`
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (err instanceof CliError) {
|
|
412
|
+
return { code: err.exitCode, message: err.message };
|
|
413
|
+
}
|
|
414
|
+
if (err instanceof Error) {
|
|
415
|
+
return { code: EXIT_USAGE, message: err.message };
|
|
416
|
+
}
|
|
417
|
+
return { code: EXIT_USAGE, message: String(err) };
|
|
418
|
+
}
|
|
419
|
+
function isProxyErrorEnvelope(value) {
|
|
420
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "error" in value;
|
|
421
|
+
}
|
|
422
|
+
async function readErrorEnvelope(response) {
|
|
423
|
+
const status = response.status;
|
|
424
|
+
let raw;
|
|
425
|
+
try {
|
|
426
|
+
raw = await response.json();
|
|
427
|
+
} catch {
|
|
428
|
+
return {
|
|
429
|
+
code: `http_${status}`,
|
|
430
|
+
message: response.statusText || "request failed"
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (isProxyErrorEnvelope(raw) && raw.error) {
|
|
434
|
+
return {
|
|
435
|
+
code: raw.error.code ?? `http_${status}`,
|
|
436
|
+
message: raw.error.message ?? response.statusText ?? "request failed"
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
code: `http_${status}`,
|
|
441
|
+
message: response.statusText || "request failed"
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function stripTrailingSlash(url) {
|
|
445
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
446
|
+
}
|
|
447
|
+
function createProxyClient(cfg) {
|
|
448
|
+
const base = stripTrailingSlash(cfg.baseUrl);
|
|
449
|
+
const fetchImpl = cfg.fetch ?? globalThis.fetch.bind(globalThis);
|
|
450
|
+
async function userBearer() {
|
|
451
|
+
const tok = await cfg.getAuthToken();
|
|
452
|
+
return `Bearer ${tok}`;
|
|
453
|
+
}
|
|
454
|
+
async function call(url, init) {
|
|
455
|
+
let response;
|
|
456
|
+
try {
|
|
457
|
+
response = await fetchImpl(url, init);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
460
|
+
throw new ProxyError(0, "network_error", `proxy unreachable: ${message}`);
|
|
461
|
+
}
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
const env = await readErrorEnvelope(response);
|
|
464
|
+
throw new ProxyError(response.status, env.code, env.message);
|
|
465
|
+
}
|
|
466
|
+
if (response.status === 204) {
|
|
467
|
+
return void 0;
|
|
468
|
+
}
|
|
469
|
+
return await response.json();
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
async whoami() {
|
|
473
|
+
return call(`${base}/api/whoami`, {
|
|
474
|
+
method: "GET",
|
|
475
|
+
headers: {
|
|
476
|
+
Authorization: await userBearer(),
|
|
477
|
+
Accept: "application/json"
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
async deployInit(req) {
|
|
482
|
+
return call(`${base}/api/deploy/init`, {
|
|
483
|
+
method: "POST",
|
|
484
|
+
headers: {
|
|
485
|
+
Authorization: await userBearer(),
|
|
486
|
+
Accept: "application/json",
|
|
487
|
+
"Content-Type": "application/json"
|
|
488
|
+
},
|
|
489
|
+
body: JSON.stringify(req)
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
async deployUpload(req) {
|
|
493
|
+
const url = `${base}/api/deploy/${encodeURIComponent(
|
|
494
|
+
req.deployId
|
|
495
|
+
)}/upload?path=${encodeURIComponent(req.path)}`;
|
|
496
|
+
return call(url, {
|
|
497
|
+
method: "PUT",
|
|
498
|
+
headers: {
|
|
499
|
+
Authorization: `Bearer ${req.jwt}`,
|
|
500
|
+
Accept: "application/json",
|
|
501
|
+
"Content-Type": req.contentType ?? "application/octet-stream"
|
|
502
|
+
},
|
|
503
|
+
body: req.body
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
async deployFinalize(req) {
|
|
507
|
+
const url = `${base}/api/deploy/${encodeURIComponent(req.deployId)}/finalize`;
|
|
508
|
+
return call(url, {
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: {
|
|
511
|
+
Authorization: `Bearer ${req.jwt}`,
|
|
512
|
+
Accept: "application/json",
|
|
513
|
+
"Content-Type": "application/json"
|
|
514
|
+
},
|
|
515
|
+
body: JSON.stringify({ mode: req.mode, files: req.files })
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
async siteDeploys(req) {
|
|
519
|
+
const url = `${base}/api/site/${encodeURIComponent(req.site)}/deploys`;
|
|
520
|
+
return call(url, {
|
|
521
|
+
method: "GET",
|
|
522
|
+
headers: {
|
|
523
|
+
Authorization: await userBearer(),
|
|
524
|
+
Accept: "application/json"
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
},
|
|
528
|
+
async sitePromote(req) {
|
|
529
|
+
const url = `${base}/api/site/${encodeURIComponent(req.site)}/promote`;
|
|
530
|
+
return call(url, {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: {
|
|
533
|
+
Authorization: await userBearer(),
|
|
534
|
+
Accept: "application/json"
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
},
|
|
538
|
+
async siteRollback(req) {
|
|
539
|
+
const url = `${base}/api/site/${encodeURIComponent(req.site)}/rollback`;
|
|
540
|
+
return call(url, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: {
|
|
543
|
+
Authorization: await userBearer(),
|
|
544
|
+
Accept: "application/json",
|
|
545
|
+
"Content-Type": "application/json"
|
|
546
|
+
},
|
|
547
|
+
body: JSON.stringify({ to: req.to })
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/lib/upload.ts
|
|
554
|
+
import { readFile as defaultReadFile } from "fs/promises";
|
|
555
|
+
var DEFAULT_CONCURRENCY = 6;
|
|
556
|
+
var MIME_BY_EXT = Object.freeze({
|
|
557
|
+
// text
|
|
558
|
+
html: "text/html",
|
|
559
|
+
htm: "text/html",
|
|
560
|
+
css: "text/css",
|
|
561
|
+
js: "text/javascript",
|
|
562
|
+
mjs: "text/javascript",
|
|
563
|
+
cjs: "text/javascript",
|
|
564
|
+
json: "application/json",
|
|
565
|
+
txt: "text/plain",
|
|
566
|
+
md: "text/markdown",
|
|
567
|
+
xml: "application/xml",
|
|
568
|
+
csv: "text/csv",
|
|
569
|
+
// images
|
|
570
|
+
svg: "image/svg+xml",
|
|
571
|
+
png: "image/png",
|
|
572
|
+
jpg: "image/jpeg",
|
|
573
|
+
jpeg: "image/jpeg",
|
|
574
|
+
gif: "image/gif",
|
|
575
|
+
webp: "image/webp",
|
|
576
|
+
avif: "image/avif",
|
|
577
|
+
ico: "image/x-icon",
|
|
578
|
+
bmp: "image/bmp",
|
|
579
|
+
// fonts
|
|
580
|
+
woff: "font/woff",
|
|
581
|
+
woff2: "font/woff2",
|
|
582
|
+
ttf: "font/ttf",
|
|
583
|
+
otf: "font/otf",
|
|
584
|
+
eot: "application/vnd.ms-fontobject",
|
|
585
|
+
// a/v
|
|
586
|
+
mp4: "video/mp4",
|
|
587
|
+
webm: "video/webm",
|
|
588
|
+
ogg: "audio/ogg",
|
|
589
|
+
mp3: "audio/mpeg",
|
|
590
|
+
wav: "audio/wav",
|
|
591
|
+
// other
|
|
592
|
+
pdf: "application/pdf",
|
|
593
|
+
wasm: "application/wasm"
|
|
594
|
+
});
|
|
595
|
+
function getContentType(filename) {
|
|
596
|
+
const dot = filename.lastIndexOf(".");
|
|
597
|
+
if (dot < 0 || dot === filename.length - 1) {
|
|
598
|
+
return "application/octet-stream";
|
|
599
|
+
}
|
|
600
|
+
const ext = filename.slice(dot + 1).toLowerCase();
|
|
601
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
602
|
+
}
|
|
603
|
+
function createLimit(max) {
|
|
604
|
+
let active = 0;
|
|
605
|
+
const queue = [];
|
|
606
|
+
const acquire = () => {
|
|
607
|
+
if (active < max) {
|
|
608
|
+
active += 1;
|
|
609
|
+
return Promise.resolve();
|
|
610
|
+
}
|
|
611
|
+
return new Promise((resolve6) => {
|
|
612
|
+
queue.push(() => {
|
|
613
|
+
active += 1;
|
|
614
|
+
resolve6();
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
const release = () => {
|
|
619
|
+
active -= 1;
|
|
620
|
+
const next = queue.shift();
|
|
621
|
+
if (next) next();
|
|
622
|
+
};
|
|
623
|
+
return async (fn) => {
|
|
624
|
+
await acquire();
|
|
625
|
+
try {
|
|
626
|
+
return await fn();
|
|
627
|
+
} finally {
|
|
628
|
+
release();
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
async function uploadFiles(options, deps = {}) {
|
|
633
|
+
const read = deps.readFile ?? defaultReadFile;
|
|
634
|
+
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
635
|
+
const limit = createLimit(concurrency);
|
|
636
|
+
const total = options.files.length;
|
|
637
|
+
const uploaded = [];
|
|
638
|
+
const errors = [];
|
|
639
|
+
let totalSize = 0;
|
|
640
|
+
let done = 0;
|
|
641
|
+
const tasks = options.files.map(
|
|
642
|
+
(file) => limit(async () => {
|
|
643
|
+
try {
|
|
644
|
+
const body = await read(file.absPath);
|
|
645
|
+
const bodyAsBodyInit = body;
|
|
646
|
+
await options.client.deployUpload({
|
|
647
|
+
deployId: options.deployId,
|
|
648
|
+
jwt: options.jwt,
|
|
649
|
+
path: file.relPath,
|
|
650
|
+
body: bodyAsBodyInit,
|
|
651
|
+
contentType: getContentType(file.relPath)
|
|
652
|
+
});
|
|
653
|
+
uploaded.push(file.relPath);
|
|
654
|
+
totalSize += body.byteLength;
|
|
655
|
+
} catch (err) {
|
|
656
|
+
const message = err instanceof Error ? err.message : "unknown upload error";
|
|
657
|
+
errors.push(`${file.relPath}: ${message}`);
|
|
658
|
+
} finally {
|
|
659
|
+
done += 1;
|
|
660
|
+
if (options.onProgress) {
|
|
661
|
+
options.onProgress({
|
|
662
|
+
uploaded: done,
|
|
663
|
+
total,
|
|
664
|
+
current: file.relPath
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
})
|
|
670
669
|
);
|
|
670
|
+
await Promise.all(tasks);
|
|
671
|
+
return {
|
|
672
|
+
fileCount: uploaded.length,
|
|
673
|
+
totalSize,
|
|
674
|
+
uploaded,
|
|
675
|
+
errors
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/output/envelope.ts
|
|
680
|
+
function buildEnvelope(command, success, data) {
|
|
681
|
+
return {
|
|
682
|
+
schemaVersion: "1",
|
|
683
|
+
command,
|
|
684
|
+
success,
|
|
685
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
686
|
+
...data
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function buildErrorEnvelope(command, code, message, issues) {
|
|
690
|
+
const error = {
|
|
691
|
+
code,
|
|
692
|
+
message
|
|
693
|
+
};
|
|
694
|
+
if (issues !== void 0) {
|
|
695
|
+
error.issues = issues;
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
schemaVersion: "1",
|
|
699
|
+
command,
|
|
700
|
+
success: false,
|
|
701
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
702
|
+
error
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/commands/deploy.ts
|
|
707
|
+
var defaultReadPlatformYaml = async (cwd) => {
|
|
708
|
+
return readFile2(resolve2(cwd, "platform.yaml"), "utf-8");
|
|
709
|
+
};
|
|
710
|
+
function emitJson(envelope) {
|
|
711
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
712
|
+
}
|
|
713
|
+
async function readAndParseConfig(cwd, read) {
|
|
714
|
+
let raw;
|
|
715
|
+
try {
|
|
716
|
+
raw = await read(cwd);
|
|
717
|
+
} catch (err) {
|
|
718
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
719
|
+
throw new ConfigError(
|
|
720
|
+
`platform.yaml not found in ${cwd}. See docs/platform-yaml.md.`
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
725
|
+
const r = parsePlatformYaml(raw);
|
|
726
|
+
if (!r.ok) throw new ConfigError(r.error);
|
|
727
|
+
return r.value;
|
|
671
728
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
729
|
+
function syntheticSha() {
|
|
730
|
+
return `nogit-${Date.now().toString(36)}`;
|
|
731
|
+
}
|
|
732
|
+
function rethrowProxy(prefix, err) {
|
|
733
|
+
if (err instanceof ProxyError) {
|
|
734
|
+
throw new ProxyError(
|
|
735
|
+
err.status,
|
|
736
|
+
err.code,
|
|
737
|
+
`${prefix} (${err.code}): ${err.message}`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
if (err instanceof Error) throw new StorageError(`${prefix}: ${err.message}`);
|
|
741
|
+
throw new StorageError(`${prefix}: ${String(err)}`);
|
|
742
|
+
}
|
|
743
|
+
async function deploy(options, deps = {}) {
|
|
744
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
745
|
+
const env = deps.env ?? process.env;
|
|
746
|
+
const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml;
|
|
747
|
+
const resolveId = deps.resolveIdentity ?? resolveIdentity;
|
|
748
|
+
const mkClient = deps.createProxyClient ?? createProxyClient;
|
|
749
|
+
const gitState = deps.getGitState ?? getGitState;
|
|
750
|
+
const build = deps.runBuild ?? runBuild;
|
|
751
|
+
const walk = deps.walkFiles ?? walkFiles;
|
|
752
|
+
const upload = deps.uploadFiles ?? uploadFiles;
|
|
753
|
+
const success = deps.logSuccess ?? ((s) => log.success(s));
|
|
754
|
+
const info = deps.logInfo ?? ((s) => log.info(s));
|
|
755
|
+
const warn = deps.logWarn ?? ((s) => log.warn(s));
|
|
756
|
+
const error = deps.logError ?? ((s) => log.error(s));
|
|
757
|
+
const exit = deps.exit ?? exitWithCode;
|
|
758
|
+
try {
|
|
759
|
+
const identity = await resolveId({ env });
|
|
760
|
+
if (!identity) {
|
|
761
|
+
throw new CredentialError(
|
|
762
|
+
"No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
const config = await readAndParseConfig(cwd, readYaml);
|
|
766
|
+
const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
|
|
767
|
+
const client = mkClient({
|
|
768
|
+
baseUrl,
|
|
769
|
+
getAuthToken: () => identity.token
|
|
770
|
+
});
|
|
771
|
+
let me;
|
|
772
|
+
try {
|
|
773
|
+
me = await client.whoami();
|
|
774
|
+
} catch (err) {
|
|
775
|
+
rethrowProxy("whoami preflight failed", err);
|
|
776
|
+
}
|
|
777
|
+
if (!me.authorizedSites.includes(config.site)) {
|
|
778
|
+
const sitesLine = me.authorizedSites.length > 0 ? me.authorizedSites.join(", ") : "(no sites authorized)";
|
|
779
|
+
throw new CredentialError(
|
|
780
|
+
[
|
|
781
|
+
`Site '${config.site}' is not registered for your GitHub identity.`,
|
|
782
|
+
``,
|
|
783
|
+
` You are: ${me.login}`,
|
|
784
|
+
` Authorized sites: ${sitesLine}`,
|
|
785
|
+
``,
|
|
786
|
+
`Likely causes (most common first):`,
|
|
787
|
+
` 1. Platform admin has not added '${config.site}' to artemis`,
|
|
788
|
+
` 'config/sites.yaml' yet (one-time, per site).`,
|
|
789
|
+
` 2. You are not in any GitHub team listed for '${config.site}'.`,
|
|
790
|
+
``,
|
|
791
|
+
`Runbook:`,
|
|
792
|
+
` https://github.com/freeCodeCamp/infra/blob/main/docs/runbooks/01-deploy-new-constellation-site.md`
|
|
793
|
+
].join("\n")
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
const git = gitState();
|
|
797
|
+
if (git.dirty) {
|
|
798
|
+
warn(
|
|
799
|
+
"git working tree is dirty \u2014 uncommitted changes will not be reflected."
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
const sha = git.hash ?? syntheticSha();
|
|
803
|
+
const outputDir = options.dir ?? config.build.output;
|
|
804
|
+
const buildResult = await build({
|
|
805
|
+
command: config.build.command,
|
|
806
|
+
cwd,
|
|
807
|
+
outputDir
|
|
808
|
+
});
|
|
809
|
+
if (buildResult.skipped) {
|
|
810
|
+
info("build.command not set \u2014 using pre-built output.");
|
|
811
|
+
}
|
|
812
|
+
const resolvedOutputDir = buildResult.outputDir;
|
|
813
|
+
const walked = walk(resolvedOutputDir);
|
|
814
|
+
const ignore = createIgnoreFilter(config.deploy.ignore);
|
|
815
|
+
const filtered = walked.filter((f) => !ignore(f.relPath));
|
|
816
|
+
if (filtered.length === 0) {
|
|
817
|
+
throw new GitError(`No files to deploy under ${resolvedOutputDir}.`);
|
|
818
|
+
}
|
|
819
|
+
const fileList = filtered.map((f) => f.relPath);
|
|
820
|
+
let initResult;
|
|
821
|
+
try {
|
|
822
|
+
initResult = await client.deployInit({
|
|
823
|
+
site: config.site,
|
|
824
|
+
sha,
|
|
825
|
+
files: fileList
|
|
826
|
+
});
|
|
827
|
+
} catch (err) {
|
|
828
|
+
rethrowProxy("deploy init failed", err);
|
|
829
|
+
}
|
|
830
|
+
const uploadResult = await upload({
|
|
831
|
+
client,
|
|
832
|
+
deployId: initResult.deployId,
|
|
833
|
+
jwt: initResult.jwt,
|
|
834
|
+
files: filtered
|
|
835
|
+
});
|
|
836
|
+
if (uploadResult.errors.length > 0) {
|
|
837
|
+
const message = `Upload partially failed: ${uploadResult.errors.length} file(s) failed:
|
|
838
|
+
- ${uploadResult.errors.join("\n - ")}`;
|
|
839
|
+
throw new PartialUploadError(message);
|
|
840
|
+
}
|
|
841
|
+
const mode = options.promote ? "production" : "preview";
|
|
842
|
+
let finalizeResult;
|
|
843
|
+
try {
|
|
844
|
+
finalizeResult = await client.deployFinalize({
|
|
845
|
+
deployId: initResult.deployId,
|
|
846
|
+
jwt: initResult.jwt,
|
|
847
|
+
mode,
|
|
848
|
+
files: fileList
|
|
849
|
+
});
|
|
850
|
+
} catch (err) {
|
|
851
|
+
rethrowProxy("deploy finalize failed", err);
|
|
852
|
+
}
|
|
853
|
+
if (options.json) {
|
|
854
|
+
emitJson(
|
|
855
|
+
buildEnvelope("deploy", true, {
|
|
856
|
+
deployId: finalizeResult.deployId,
|
|
857
|
+
url: finalizeResult.url,
|
|
858
|
+
mode: finalizeResult.mode,
|
|
859
|
+
site: config.site,
|
|
860
|
+
sha,
|
|
861
|
+
fileCount: uploadResult.fileCount,
|
|
862
|
+
totalSize: uploadResult.totalSize,
|
|
863
|
+
identitySource: identity.source
|
|
864
|
+
})
|
|
865
|
+
);
|
|
866
|
+
} else {
|
|
867
|
+
const sizeKB = (uploadResult.totalSize / 1024).toFixed(1);
|
|
868
|
+
const nextLine = mode === "preview" ? "Next: universe static promote" : "Promoted to production.";
|
|
869
|
+
success(
|
|
870
|
+
[
|
|
871
|
+
`Deployed ${finalizeResult.deployId}`,
|
|
872
|
+
``,
|
|
873
|
+
` Site: ${config.site}`,
|
|
874
|
+
` Files: ${uploadResult.fileCount}`,
|
|
875
|
+
` Size: ${sizeKB} KB`,
|
|
876
|
+
` Mode: ${mode}`,
|
|
877
|
+
` URL: ${finalizeResult.url}`,
|
|
878
|
+
``,
|
|
879
|
+
nextLine
|
|
880
|
+
].join("\n")
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
} catch (err) {
|
|
884
|
+
let code;
|
|
885
|
+
let message;
|
|
886
|
+
if (err instanceof ProxyError) {
|
|
887
|
+
code = err.exitCode;
|
|
888
|
+
message = err.message;
|
|
889
|
+
} else if (err instanceof CliError) {
|
|
890
|
+
code = err.exitCode;
|
|
891
|
+
message = err.message;
|
|
892
|
+
} else if (err instanceof Error) {
|
|
893
|
+
code = EXIT_USAGE;
|
|
894
|
+
message = err.message;
|
|
895
|
+
} else {
|
|
896
|
+
code = EXIT_USAGE;
|
|
897
|
+
message = String(err);
|
|
898
|
+
}
|
|
899
|
+
if (options.json) {
|
|
900
|
+
emitJson(buildErrorEnvelope("deploy", code, message));
|
|
901
|
+
} else {
|
|
902
|
+
error(message);
|
|
903
|
+
}
|
|
904
|
+
exit(code, message);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/commands/login.ts
|
|
909
|
+
import { log as log2 } from "@clack/prompts";
|
|
910
|
+
|
|
911
|
+
// src/lib/device-flow.ts
|
|
912
|
+
var DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
913
|
+
var ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
914
|
+
var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
915
|
+
function isAccessTokenSuccess(body) {
|
|
916
|
+
return "access_token" in body && typeof body.access_token === "string";
|
|
917
|
+
}
|
|
918
|
+
var defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
919
|
+
async function runDeviceFlow(opts) {
|
|
920
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
921
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
922
|
+
const startResp = await fetchImpl(DEVICE_CODE_URL, {
|
|
923
|
+
method: "POST",
|
|
924
|
+
headers: {
|
|
925
|
+
Accept: "application/json",
|
|
926
|
+
"Content-Type": "application/json"
|
|
927
|
+
},
|
|
928
|
+
body: JSON.stringify({
|
|
929
|
+
client_id: opts.clientId,
|
|
930
|
+
...opts.scope ? { scope: opts.scope } : {}
|
|
931
|
+
})
|
|
675
932
|
});
|
|
676
|
-
|
|
677
|
-
|
|
933
|
+
if (!startResp.ok) {
|
|
934
|
+
throw new Error(`device code request failed: HTTP ${startResp.status}`);
|
|
935
|
+
}
|
|
936
|
+
const start = await startResp.json();
|
|
937
|
+
await opts.onPrompt({
|
|
938
|
+
userCode: start.user_code,
|
|
939
|
+
verificationUri: start.verification_uri,
|
|
940
|
+
expiresIn: start.expires_in
|
|
678
941
|
});
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
);
|
|
694
|
-
|
|
942
|
+
let intervalSec = start.interval > 0 ? start.interval : 5;
|
|
943
|
+
while (true) {
|
|
944
|
+
await sleep(intervalSec * 1e3);
|
|
945
|
+
const pollResp = await fetchImpl(ACCESS_TOKEN_URL, {
|
|
946
|
+
method: "POST",
|
|
947
|
+
headers: {
|
|
948
|
+
Accept: "application/json",
|
|
949
|
+
"Content-Type": "application/json"
|
|
950
|
+
},
|
|
951
|
+
body: JSON.stringify({
|
|
952
|
+
client_id: opts.clientId,
|
|
953
|
+
device_code: start.device_code,
|
|
954
|
+
grant_type: DEVICE_CODE_GRANT
|
|
955
|
+
})
|
|
956
|
+
});
|
|
957
|
+
if (!pollResp.ok) {
|
|
958
|
+
throw new Error(`device flow poll failed: HTTP ${pollResp.status}`);
|
|
959
|
+
}
|
|
960
|
+
const body = await pollResp.json();
|
|
961
|
+
if (isAccessTokenSuccess(body)) {
|
|
962
|
+
return body.access_token;
|
|
963
|
+
}
|
|
964
|
+
switch (body.error) {
|
|
965
|
+
case "authorization_pending":
|
|
966
|
+
continue;
|
|
967
|
+
case "slow_down":
|
|
968
|
+
intervalSec += 5;
|
|
969
|
+
continue;
|
|
970
|
+
case "expired_token":
|
|
971
|
+
throw new Error(
|
|
972
|
+
"device flow expired before authorization. Run `universe login` again."
|
|
973
|
+
);
|
|
974
|
+
case "access_denied":
|
|
975
|
+
throw new Error("device flow access denied by user.");
|
|
976
|
+
default:
|
|
977
|
+
throw new Error(
|
|
978
|
+
body.error_description ? `device flow error: ${body.error}: ${body.error_description}` : `device flow error: ${body.error}`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
695
981
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// src/commands/login.ts
|
|
985
|
+
var DEFAULT_SCOPE = "read:org user:email";
|
|
986
|
+
function emitJson2(envelope) {
|
|
987
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
988
|
+
}
|
|
989
|
+
async function login(options, deps = {}) {
|
|
990
|
+
const env = deps.env ?? process.env;
|
|
991
|
+
const runFlow = deps.runDeviceFlow ?? runDeviceFlow;
|
|
992
|
+
const save = deps.saveToken ?? saveToken;
|
|
993
|
+
const load = deps.loadToken ?? loadToken;
|
|
994
|
+
const success = deps.logSuccess ?? ((s) => log2.success(s));
|
|
995
|
+
const info = deps.logInfo ?? ((s) => log2.info(s));
|
|
996
|
+
const error = deps.logError ?? ((s) => log2.error(s));
|
|
997
|
+
const exit = deps.exit ?? exitWithCode;
|
|
998
|
+
const envClientId = env["UNIVERSE_GH_CLIENT_ID"];
|
|
999
|
+
const clientId = envClientId && envClientId.trim().length > 0 ? envClientId : DEFAULT_GH_CLIENT_ID;
|
|
1000
|
+
if (!options.force) {
|
|
1001
|
+
const existing = await load();
|
|
1002
|
+
if (existing) {
|
|
1003
|
+
const msg = "Already logged in. Run `universe logout` first or pass --force to replace the stored token.";
|
|
1004
|
+
if (options.json) {
|
|
1005
|
+
emitJson2({
|
|
1006
|
+
schemaVersion: "1",
|
|
1007
|
+
command: "login",
|
|
1008
|
+
success: false,
|
|
1009
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1010
|
+
error: { code: EXIT_CONFIRM, message: msg }
|
|
1011
|
+
});
|
|
1012
|
+
} else {
|
|
1013
|
+
error(msg);
|
|
1014
|
+
}
|
|
1015
|
+
exit(EXIT_CONFIRM, msg);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
700
1018
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1019
|
+
let token;
|
|
1020
|
+
try {
|
|
1021
|
+
token = await runFlow({
|
|
1022
|
+
clientId,
|
|
1023
|
+
scope: DEFAULT_SCOPE,
|
|
1024
|
+
onPrompt: ({ userCode, verificationUri, expiresIn }) => {
|
|
1025
|
+
if (options.json) {
|
|
1026
|
+
emitJson2(
|
|
1027
|
+
buildEnvelope("login", true, {
|
|
1028
|
+
userCode,
|
|
1029
|
+
verificationUri,
|
|
1030
|
+
expiresIn,
|
|
1031
|
+
stored: false
|
|
1032
|
+
})
|
|
1033
|
+
);
|
|
1034
|
+
} else {
|
|
1035
|
+
info(
|
|
1036
|
+
[
|
|
1037
|
+
`Open ${verificationUri} in your browser`,
|
|
1038
|
+
`and enter code: ${userCode}`,
|
|
1039
|
+
`(code expires in ${Math.round(expiresIn / 60)} min)`
|
|
1040
|
+
].join("\n")
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1047
|
+
if (options.json) {
|
|
1048
|
+
emitJson2({
|
|
1049
|
+
schemaVersion: "1",
|
|
1050
|
+
command: "login",
|
|
1051
|
+
success: false,
|
|
1052
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1053
|
+
error: { code: EXIT_CREDENTIALS, message }
|
|
1054
|
+
});
|
|
1055
|
+
} else {
|
|
1056
|
+
error(message);
|
|
1057
|
+
}
|
|
1058
|
+
exit(EXIT_CREDENTIALS, message);
|
|
712
1059
|
return;
|
|
713
1060
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
);
|
|
736
|
-
exitWithCode(
|
|
737
|
-
EXIT_PARTIAL,
|
|
738
|
-
`Upload partially failed: ${uploadResult.errors.length} file(s) failed`
|
|
739
|
-
);
|
|
1061
|
+
await save(token);
|
|
1062
|
+
if (options.json) {
|
|
1063
|
+
emitJson2(buildEnvelope("login", true, { stored: true }));
|
|
1064
|
+
} else {
|
|
1065
|
+
success("Logged in. Token stored at ~/.config/universe-cli/token.");
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/commands/logout.ts
|
|
1070
|
+
import { log as log3 } from "@clack/prompts";
|
|
1071
|
+
function emitJson3(envelope) {
|
|
1072
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1073
|
+
}
|
|
1074
|
+
async function logout(options, deps = {}) {
|
|
1075
|
+
const load = deps.loadToken ?? loadToken;
|
|
1076
|
+
const del = deps.deleteToken ?? deleteToken;
|
|
1077
|
+
const success = deps.logSuccess ?? ((s) => log3.success(s));
|
|
1078
|
+
const info = deps.logInfo ?? ((s) => log3.info(s));
|
|
1079
|
+
const existing = await load();
|
|
1080
|
+
await del();
|
|
1081
|
+
if (options.json) {
|
|
1082
|
+
emitJson3(buildEnvelope("logout", true, { removed: existing !== null }));
|
|
740
1083
|
return;
|
|
741
1084
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
fileCount: uploadResult.fileCount,
|
|
748
|
-
totalSize: uploadResult.totalSize
|
|
749
|
-
});
|
|
750
|
-
await writeAlias(client, bucket, site, "preview", deployId);
|
|
751
|
-
const sizeKB = (uploadResult.totalSize / 1024).toFixed(1);
|
|
752
|
-
const previewDomain = config.domain?.preview ?? `preview.${site}`;
|
|
753
|
-
const humanMsg = [
|
|
754
|
-
`Deployed ${deployId}`,
|
|
755
|
-
``,
|
|
756
|
-
` Site: ${site}`,
|
|
757
|
-
` Files: ${uploadResult.fileCount}`,
|
|
758
|
-
` Size: ${sizeKB} KB`,
|
|
759
|
-
` Alias: preview`,
|
|
760
|
-
` Preview: https://${previewDomain}`,
|
|
761
|
-
``,
|
|
762
|
-
`Next: universe static promote`
|
|
763
|
-
].join("\n");
|
|
764
|
-
outputSuccess(ctx, humanMsg, {
|
|
765
|
-
deployId,
|
|
766
|
-
site,
|
|
767
|
-
fileCount: uploadResult.fileCount,
|
|
768
|
-
totalSize: uploadResult.totalSize,
|
|
769
|
-
alias: "preview",
|
|
770
|
-
previewDomain
|
|
771
|
-
});
|
|
1085
|
+
if (existing) {
|
|
1086
|
+
success("Logged out. Stored token removed.");
|
|
1087
|
+
} else {
|
|
1088
|
+
info("No token was stored. Nothing to remove.");
|
|
1089
|
+
}
|
|
772
1090
|
}
|
|
773
1091
|
|
|
774
|
-
// src/
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1092
|
+
// src/commands/ls.ts
|
|
1093
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1094
|
+
import { resolve as resolve3 } from "path";
|
|
1095
|
+
import { log as log4 } from "@clack/prompts";
|
|
1096
|
+
var defaultReadPlatformYaml2 = async (cwd) => {
|
|
1097
|
+
return readFile3(resolve3(cwd, "platform.yaml"), "utf-8");
|
|
1098
|
+
};
|
|
1099
|
+
function emitJson4(envelope) {
|
|
1100
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1101
|
+
}
|
|
1102
|
+
var DEPLOY_ID_RE = /^(\d{8})-(\d{6})-([a-f0-9]+)$/i;
|
|
1103
|
+
function parseDeployId(deployId) {
|
|
1104
|
+
const m = DEPLOY_ID_RE.exec(deployId);
|
|
1105
|
+
if (!m) return { deployId, timestamp: null, sha: null };
|
|
1106
|
+
const [, ymd, hms, sha] = m;
|
|
1107
|
+
if (!ymd || !hms || !sha) return { deployId, timestamp: null, sha: null };
|
|
1108
|
+
const iso = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;
|
|
1109
|
+
return { deployId, timestamp: iso, sha };
|
|
1110
|
+
}
|
|
1111
|
+
async function readSiteFromYaml(cwd, read) {
|
|
1112
|
+
let raw;
|
|
1113
|
+
try {
|
|
1114
|
+
raw = await read(cwd);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
1117
|
+
return null;
|
|
784
1118
|
}
|
|
1119
|
+
throw err;
|
|
785
1120
|
}
|
|
786
|
-
|
|
1121
|
+
const r = parsePlatformYaml(raw);
|
|
1122
|
+
if (!r.ok) throw new ConfigError(r.error);
|
|
1123
|
+
const config = r.value;
|
|
1124
|
+
return config.site;
|
|
787
1125
|
}
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
|
|
1126
|
+
function formatTable(deploys) {
|
|
1127
|
+
const header = ["DEPLOY ID", "TIMESTAMP", "SHA"];
|
|
1128
|
+
const rows = deploys.map((d) => [
|
|
1129
|
+
d.deployId,
|
|
1130
|
+
d.timestamp ? d.timestamp.replace("T", " ").replace("Z", "") : "\u2014",
|
|
1131
|
+
d.sha ?? "\u2014"
|
|
1132
|
+
]);
|
|
1133
|
+
const widths = header.map(
|
|
1134
|
+
(h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
|
|
1135
|
+
);
|
|
1136
|
+
const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
|
|
1137
|
+
return [fmt(header), ...rows.map(fmt)].join("\n");
|
|
1138
|
+
}
|
|
1139
|
+
async function ls(options, deps = {}) {
|
|
1140
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1141
|
+
const env = deps.env ?? process.env;
|
|
1142
|
+
const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml2;
|
|
1143
|
+
const resolveId = deps.resolveIdentity ?? resolveIdentity;
|
|
1144
|
+
const mkClient = deps.createProxyClient ?? createProxyClient;
|
|
1145
|
+
const success = deps.logSuccess ?? ((s) => log4.success(s));
|
|
1146
|
+
const info = deps.logInfo ?? ((s) => log4.info(s));
|
|
1147
|
+
const error = deps.logError ?? ((s) => log4.error(s));
|
|
1148
|
+
const exit = deps.exit ?? exitWithCode;
|
|
1149
|
+
try {
|
|
1150
|
+
const identity = await resolveId({ env });
|
|
1151
|
+
if (!identity) {
|
|
1152
|
+
throw new CredentialError(
|
|
1153
|
+
"No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
let site = options.site?.trim() || null;
|
|
1157
|
+
if (!site) {
|
|
1158
|
+
site = await readSiteFromYaml(cwd, readYaml);
|
|
1159
|
+
}
|
|
1160
|
+
if (!site) {
|
|
1161
|
+
throw new ConfigError(
|
|
1162
|
+
"No site to list. Run from a directory with `platform.yaml`, or pass `--site <name>`."
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
|
|
1166
|
+
const client = mkClient({
|
|
1167
|
+
baseUrl,
|
|
1168
|
+
getAuthToken: () => identity.token
|
|
1169
|
+
});
|
|
1170
|
+
const raw = await client.siteDeploys({ site });
|
|
1171
|
+
const deploys = raw.map((d) => parseDeployId(d.deployId));
|
|
1172
|
+
if (options.json) {
|
|
1173
|
+
emitJson4(
|
|
1174
|
+
buildEnvelope("ls", true, {
|
|
1175
|
+
site,
|
|
1176
|
+
deploys,
|
|
1177
|
+
identitySource: identity.source
|
|
1178
|
+
})
|
|
1179
|
+
);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (deploys.length === 0) {
|
|
1183
|
+
info(`(no deploys for ${site})`);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
success(formatTable(deploys));
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
const { code, message } = wrapProxyError("ls", err);
|
|
1189
|
+
if (options.json) {
|
|
1190
|
+
emitJson4(buildErrorEnvelope("ls", code, message));
|
|
1191
|
+
} else {
|
|
1192
|
+
error(message);
|
|
1193
|
+
}
|
|
1194
|
+
exit(code, message);
|
|
1195
|
+
}
|
|
792
1196
|
}
|
|
793
1197
|
|
|
794
1198
|
// src/commands/promote.ts
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
`
|
|
1199
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1200
|
+
import { resolve as resolve4 } from "path";
|
|
1201
|
+
import { log as log5 } from "@clack/prompts";
|
|
1202
|
+
var defaultReadPlatformYaml3 = async (cwd) => {
|
|
1203
|
+
return readFile4(resolve4(cwd, "platform.yaml"), "utf-8");
|
|
1204
|
+
};
|
|
1205
|
+
function emitJson5(envelope) {
|
|
1206
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1207
|
+
}
|
|
1208
|
+
async function readAndParseConfig2(cwd, read) {
|
|
1209
|
+
let raw;
|
|
1210
|
+
try {
|
|
1211
|
+
raw = await read(cwd);
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
1214
|
+
throw new ConfigError(
|
|
1215
|
+
`platform.yaml not found in ${cwd}. See docs/platform-yaml.md.`
|
|
812
1216
|
);
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1217
|
+
}
|
|
1218
|
+
throw err;
|
|
1219
|
+
}
|
|
1220
|
+
const r = parsePlatformYaml(raw);
|
|
1221
|
+
if (!r.ok) throw new ConfigError(r.error);
|
|
1222
|
+
return r.value;
|
|
1223
|
+
}
|
|
1224
|
+
async function promote(options, deps = {}) {
|
|
1225
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1226
|
+
const env = deps.env ?? process.env;
|
|
1227
|
+
const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml3;
|
|
1228
|
+
const resolveId = deps.resolveIdentity ?? resolveIdentity;
|
|
1229
|
+
const mkClient = deps.createProxyClient ?? createProxyClient;
|
|
1230
|
+
const success = deps.logSuccess ?? ((s) => log5.success(s));
|
|
1231
|
+
const error = deps.logError ?? ((s) => log5.error(s));
|
|
1232
|
+
const exit = deps.exit ?? exitWithCode;
|
|
1233
|
+
try {
|
|
1234
|
+
const identity = await resolveId({ env });
|
|
1235
|
+
if (!identity) {
|
|
1236
|
+
throw new CredentialError(
|
|
1237
|
+
"No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
|
|
816
1238
|
);
|
|
817
|
-
return;
|
|
818
1239
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1240
|
+
const config = await readAndParseConfig2(cwd, readYaml);
|
|
1241
|
+
const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
|
|
1242
|
+
const client = mkClient({
|
|
1243
|
+
baseUrl,
|
|
1244
|
+
getAuthToken: () => identity.token
|
|
1245
|
+
});
|
|
1246
|
+
let result;
|
|
1247
|
+
if (options.from) {
|
|
1248
|
+
result = await client.siteRollback({
|
|
1249
|
+
site: config.site,
|
|
1250
|
+
to: options.from
|
|
1251
|
+
});
|
|
1252
|
+
} else {
|
|
1253
|
+
result = await client.sitePromote({ site: config.site });
|
|
1254
|
+
}
|
|
1255
|
+
if (options.json) {
|
|
1256
|
+
emitJson5(
|
|
1257
|
+
buildEnvelope("promote", true, {
|
|
1258
|
+
deployId: result.deployId,
|
|
1259
|
+
url: result.url,
|
|
1260
|
+
site: config.site,
|
|
1261
|
+
identitySource: identity.source
|
|
1262
|
+
})
|
|
827
1263
|
);
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1264
|
+
} else {
|
|
1265
|
+
success(
|
|
1266
|
+
[
|
|
1267
|
+
`Promoted ${result.deployId} to production`,
|
|
1268
|
+
``,
|
|
1269
|
+
` Site: ${config.site}`,
|
|
1270
|
+
` Deploy: ${result.deployId}`,
|
|
1271
|
+
` Production: ${result.url}`
|
|
1272
|
+
].join("\n")
|
|
831
1273
|
);
|
|
832
|
-
return;
|
|
833
1274
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
` Production: https://${productionDomain}`
|
|
844
|
-
].join("\n");
|
|
845
|
-
outputSuccess(ctx, humanMsg, {
|
|
846
|
-
deployId,
|
|
847
|
-
site,
|
|
848
|
-
alias: "production",
|
|
849
|
-
productionDomain
|
|
850
|
-
});
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
const { code, message } = wrapProxyError("promote", err);
|
|
1277
|
+
if (options.json) {
|
|
1278
|
+
emitJson5(buildErrorEnvelope("promote", code, message));
|
|
1279
|
+
} else {
|
|
1280
|
+
error(message);
|
|
1281
|
+
}
|
|
1282
|
+
exit(code, message);
|
|
1283
|
+
}
|
|
851
1284
|
}
|
|
852
1285
|
|
|
853
1286
|
// src/commands/rollback.ts
|
|
854
|
-
import {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1287
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1288
|
+
import { resolve as resolve5 } from "path";
|
|
1289
|
+
import { log as log6 } from "@clack/prompts";
|
|
1290
|
+
var defaultReadPlatformYaml4 = async (cwd) => {
|
|
1291
|
+
return readFile5(resolve5(cwd, "platform.yaml"), "utf-8");
|
|
1292
|
+
};
|
|
1293
|
+
function emitJson6(envelope) {
|
|
1294
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1295
|
+
}
|
|
1296
|
+
async function readAndParseConfig3(cwd, read) {
|
|
1297
|
+
let raw;
|
|
1298
|
+
try {
|
|
1299
|
+
raw = await read(cwd);
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
1302
|
+
throw new ConfigError(
|
|
1303
|
+
`platform.yaml not found in ${cwd}. See docs/platform-yaml.md.`
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
throw err;
|
|
873
1307
|
}
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1308
|
+
const r = parsePlatformYaml(raw);
|
|
1309
|
+
if (!r.ok) throw new ConfigError(r.error);
|
|
1310
|
+
return r.value;
|
|
1311
|
+
}
|
|
1312
|
+
async function rollback(options, deps = {}) {
|
|
1313
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1314
|
+
const env = deps.env ?? process.env;
|
|
1315
|
+
const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml4;
|
|
1316
|
+
const resolveId = deps.resolveIdentity ?? resolveIdentity;
|
|
1317
|
+
const mkClient = deps.createProxyClient ?? createProxyClient;
|
|
1318
|
+
const success = deps.logSuccess ?? ((s) => log6.success(s));
|
|
1319
|
+
const error = deps.logError ?? ((s) => log6.error(s));
|
|
1320
|
+
const exit = deps.exit ?? exitWithCode;
|
|
1321
|
+
try {
|
|
1322
|
+
if (!options.to || options.to.trim().length === 0) {
|
|
1323
|
+
throw new UsageError(
|
|
1324
|
+
"rollback requires --to <deployId>. Run `universe static ls` to list past deploys."
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
const identity = await resolveId({ env });
|
|
1328
|
+
if (!identity) {
|
|
1329
|
+
throw new CredentialError(
|
|
1330
|
+
"No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
const config = await readAndParseConfig3(cwd, readYaml);
|
|
1334
|
+
const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
|
|
1335
|
+
const client = mkClient({
|
|
1336
|
+
baseUrl,
|
|
1337
|
+
getAuthToken: () => identity.token
|
|
1338
|
+
});
|
|
1339
|
+
const result = await client.siteRollback({
|
|
1340
|
+
site: config.site,
|
|
1341
|
+
to: options.to.trim()
|
|
1342
|
+
});
|
|
1343
|
+
if (options.json) {
|
|
1344
|
+
emitJson6(
|
|
1345
|
+
buildEnvelope("rollback", true, {
|
|
1346
|
+
deployId: result.deployId,
|
|
1347
|
+
url: result.url,
|
|
1348
|
+
site: config.site,
|
|
1349
|
+
identitySource: identity.source
|
|
1350
|
+
})
|
|
1351
|
+
);
|
|
1352
|
+
} else {
|
|
1353
|
+
success(
|
|
1354
|
+
[
|
|
1355
|
+
`Rolled production back to ${result.deployId}`,
|
|
1356
|
+
``,
|
|
1357
|
+
` Site: ${config.site}`,
|
|
1358
|
+
` Deploy: ${result.deployId}`,
|
|
1359
|
+
` Production: ${result.url}`
|
|
1360
|
+
].join("\n")
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
const { code, message } = wrapProxyError("rollback", err);
|
|
1365
|
+
if (options.json) {
|
|
1366
|
+
emitJson6(buildErrorEnvelope("rollback", code, message));
|
|
1367
|
+
} else {
|
|
1368
|
+
error(message);
|
|
1369
|
+
}
|
|
1370
|
+
exit(code, message);
|
|
881
1371
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/commands/whoami.ts
|
|
1375
|
+
import { log as log7 } from "@clack/prompts";
|
|
1376
|
+
var DEFAULT_PROXY_URL2 = "https://uploads.freecode.camp";
|
|
1377
|
+
function emitJson7(envelope) {
|
|
1378
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1379
|
+
}
|
|
1380
|
+
async function whoami(options, deps = {}) {
|
|
1381
|
+
const env = deps.env ?? process.env;
|
|
1382
|
+
const resolve6 = deps.resolveIdentity ?? resolveIdentity;
|
|
1383
|
+
const mkClient = deps.createProxyClient ?? createProxyClient;
|
|
1384
|
+
const success = deps.logSuccess ?? ((s) => log7.success(s));
|
|
1385
|
+
const error = deps.logError ?? ((s) => log7.error(s));
|
|
1386
|
+
const exit = deps.exit ?? exitWithCode;
|
|
1387
|
+
const identity = await resolve6({ env });
|
|
1388
|
+
if (!identity) {
|
|
1389
|
+
const msg = "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI.";
|
|
1390
|
+
if (options.json) {
|
|
1391
|
+
emitJson7(buildErrorEnvelope("whoami", EXIT_CREDENTIALS, msg));
|
|
1392
|
+
} else {
|
|
1393
|
+
error(msg);
|
|
1394
|
+
}
|
|
1395
|
+
exit(EXIT_CREDENTIALS, msg);
|
|
889
1396
|
return;
|
|
890
1397
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1398
|
+
const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL2;
|
|
1399
|
+
const client = mkClient({
|
|
1400
|
+
baseUrl,
|
|
1401
|
+
getAuthToken: () => identity.token
|
|
1402
|
+
});
|
|
1403
|
+
try {
|
|
1404
|
+
const result = await client.whoami();
|
|
1405
|
+
if (options.json) {
|
|
1406
|
+
emitJson7(
|
|
1407
|
+
buildEnvelope("whoami", true, {
|
|
1408
|
+
login: result.login,
|
|
1409
|
+
authorizedSites: result.authorizedSites,
|
|
1410
|
+
identitySource: identity.source
|
|
1411
|
+
})
|
|
1412
|
+
);
|
|
1413
|
+
} else {
|
|
1414
|
+
const sites = result.authorizedSites.length > 0 ? result.authorizedSites.join(", ") : "(no sites authorized)";
|
|
1415
|
+
success(
|
|
1416
|
+
[
|
|
1417
|
+
`Logged in as: ${result.login}`,
|
|
1418
|
+
`Authorized sites: ${sites}`,
|
|
1419
|
+
`Identity source: ${identity.source}`
|
|
1420
|
+
].join("\n")
|
|
1421
|
+
);
|
|
897
1422
|
}
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
const exitCode = err instanceof CliError ? err.exitCode : EXIT_CREDENTIALS;
|
|
1425
|
+
const message = err instanceof ProxyError ? `whoami failed (${err.code}): ${err.message}` : err instanceof Error ? err.message : String(err);
|
|
1426
|
+
if (options.json) {
|
|
1427
|
+
emitJson7(buildErrorEnvelope("whoami", exitCode, message));
|
|
1428
|
+
} else {
|
|
1429
|
+
error(message);
|
|
1430
|
+
}
|
|
1431
|
+
exit(exitCode, message);
|
|
898
1432
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// src/output/format.ts
|
|
1436
|
+
import { log as log8 } from "@clack/prompts";
|
|
1437
|
+
|
|
1438
|
+
// src/output/redact.ts
|
|
1439
|
+
var AWS_KEY_PREFIX_PATTERN = /(?:AKIA|ASIA|AROA|AIDA|ACCA|ANPA|ABIA|AGPA)[A-Z0-9]{12,}/g;
|
|
1440
|
+
var URL_CREDS_PATTERN = /https?:\/\/[^@\s]+@/g;
|
|
1441
|
+
var CREDENTIAL_CONTEXT_PATTERN = /(?:access_key_id|secret_access_key|accessKeyId|secretAccessKey|secret|password|token|key|credential|auth)\s*[=:]\s*([A-Za-z0-9/+=]{21,})/gi;
|
|
1442
|
+
var HEX_CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key)\s*[=:]\s*([a-f0-9]{32,})/gi;
|
|
1443
|
+
var JSON_CREDENTIAL_PATTERN = /"(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key|accessKeyId|secretAccessKey)"\s*:\s*"[^"]+"/gi;
|
|
1444
|
+
var BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
1445
|
+
function maskAwsKey(match) {
|
|
1446
|
+
return match.slice(0, 4) + "****" + match.slice(-4);
|
|
1447
|
+
}
|
|
1448
|
+
function maskUrlCreds(match) {
|
|
1449
|
+
const atIndex = match.lastIndexOf("@");
|
|
1450
|
+
const protocolEnd = match.indexOf("://") + 3;
|
|
1451
|
+
return match.slice(0, protocolEnd) + "****:****@" + match.slice(atIndex + 1);
|
|
1452
|
+
}
|
|
1453
|
+
function redact(value) {
|
|
1454
|
+
let result = value;
|
|
1455
|
+
result = result.replace(URL_CREDS_PATTERN, maskUrlCreds);
|
|
1456
|
+
result = result.replace(AWS_KEY_PREFIX_PATTERN, maskAwsKey);
|
|
1457
|
+
result = result.replace(BEARER_PATTERN, "Bearer ****");
|
|
1458
|
+
result = result.replace(JSON_CREDENTIAL_PATTERN, (match) => {
|
|
1459
|
+
const colonIndex = match.indexOf(":");
|
|
1460
|
+
return match.slice(0, colonIndex + 1) + '"****"';
|
|
1461
|
+
});
|
|
1462
|
+
result = result.replace(
|
|
1463
|
+
CREDENTIAL_CONTEXT_PATTERN,
|
|
1464
|
+
(_match, _secret, _offset, _full) => {
|
|
1465
|
+
const eqIndex = _match.indexOf("=");
|
|
1466
|
+
const colonIndex = _match.indexOf(":");
|
|
1467
|
+
const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
|
|
1468
|
+
const prefix = _match.slice(0, sepIndex + 1);
|
|
1469
|
+
return prefix + "****";
|
|
1470
|
+
}
|
|
1471
|
+
);
|
|
1472
|
+
result = result.replace(HEX_CREDENTIAL_CONTEXT_PATTERN, (_match, _secret) => {
|
|
1473
|
+
const eqIndex = _match.indexOf("=");
|
|
1474
|
+
const colonIndex = _match.indexOf(":");
|
|
1475
|
+
const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
|
|
1476
|
+
const prefix = _match.slice(0, sepIndex + 1);
|
|
1477
|
+
return prefix + "****";
|
|
915
1478
|
});
|
|
1479
|
+
return result;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// src/output/format.ts
|
|
1483
|
+
function outputError(ctx, code, message, issues) {
|
|
1484
|
+
const redactedMessage = redact(message);
|
|
1485
|
+
const redactedIssues = issues?.map(redact);
|
|
1486
|
+
if (ctx.json) {
|
|
1487
|
+
const envelope = buildErrorEnvelope(
|
|
1488
|
+
ctx.command,
|
|
1489
|
+
code,
|
|
1490
|
+
redactedMessage,
|
|
1491
|
+
redactedIssues
|
|
1492
|
+
);
|
|
1493
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1494
|
+
} else {
|
|
1495
|
+
log8.error(redactedMessage);
|
|
1496
|
+
}
|
|
916
1497
|
}
|
|
917
1498
|
|
|
918
1499
|
// src/cli.ts
|
|
919
|
-
var version = true ? "0.
|
|
1500
|
+
var version = true ? "0.4.0" : "0.0.0";
|
|
920
1501
|
function handleActionError(command, json, err) {
|
|
921
1502
|
const ctx = { json, command };
|
|
922
1503
|
const message = err instanceof Error ? err.message : "unknown error";
|
|
@@ -924,50 +1505,99 @@ function handleActionError(command, json, err) {
|
|
|
924
1505
|
outputError(ctx, code, message);
|
|
925
1506
|
exitWithCode(code, message);
|
|
926
1507
|
}
|
|
1508
|
+
function findFirstPositional(args) {
|
|
1509
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1510
|
+
const a = args[i];
|
|
1511
|
+
if (typeof a === "string" && !a.startsWith("-")) return i;
|
|
1512
|
+
}
|
|
1513
|
+
return -1;
|
|
1514
|
+
}
|
|
927
1515
|
function run(argv = process.argv) {
|
|
928
1516
|
const args = argv.slice(2);
|
|
929
|
-
|
|
1517
|
+
const firstPosIdx = findFirstPositional(args);
|
|
1518
|
+
const isStatic = firstPosIdx >= 0 && args[firstPosIdx] === "static";
|
|
1519
|
+
if (isStatic) {
|
|
1520
|
+
const staticArgs = [
|
|
1521
|
+
...args.slice(0, firstPosIdx),
|
|
1522
|
+
...args.slice(firstPosIdx + 1)
|
|
1523
|
+
];
|
|
930
1524
|
const staticCli = cac("universe static");
|
|
931
|
-
staticCli.command("deploy", "Deploy static site
|
|
1525
|
+
staticCli.command("deploy", "Deploy static site via the artemis proxy").option("--json", "Output as JSON").option("--promote", "Finalize as production (default: preview)").option("--dir <path>", "Override build.output dir from platform.yaml").action(
|
|
932
1526
|
async (flags) => {
|
|
933
1527
|
try {
|
|
934
1528
|
await deploy({
|
|
935
1529
|
json: flags.json ?? false,
|
|
936
|
-
|
|
937
|
-
|
|
1530
|
+
promote: flags.promote ?? false,
|
|
1531
|
+
dir: flags.dir
|
|
938
1532
|
});
|
|
939
1533
|
} catch (err) {
|
|
940
1534
|
handleActionError("deploy", flags.json ?? false, err);
|
|
941
1535
|
}
|
|
942
1536
|
}
|
|
943
1537
|
);
|
|
944
|
-
staticCli.command("promote
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1538
|
+
staticCli.command("promote", "Promote the current preview to production").option("--json", "Output as JSON").option(
|
|
1539
|
+
"--from <deployId>",
|
|
1540
|
+
"Promote a specific past deploy id (alias rewrite)"
|
|
1541
|
+
).action(async (flags) => {
|
|
1542
|
+
try {
|
|
1543
|
+
await promote({
|
|
1544
|
+
json: flags.json ?? false,
|
|
1545
|
+
from: flags.from
|
|
1546
|
+
});
|
|
1547
|
+
} catch (err) {
|
|
1548
|
+
handleActionError("promote", flags.json ?? false, err);
|
|
951
1549
|
}
|
|
952
|
-
);
|
|
953
|
-
staticCli.command("rollback", "
|
|
1550
|
+
});
|
|
1551
|
+
staticCli.command("rollback", "Rewrite production alias to a past deploy").option("--json", "Output as JSON").option("--to <deployId>", "Target deploy id (required)").action(async (flags) => {
|
|
954
1552
|
try {
|
|
955
1553
|
await rollback({
|
|
956
1554
|
json: flags.json ?? false,
|
|
957
|
-
|
|
1555
|
+
to: flags.to
|
|
958
1556
|
});
|
|
959
1557
|
} catch (err) {
|
|
960
1558
|
handleActionError("rollback", flags.json ?? false, err);
|
|
961
1559
|
}
|
|
962
1560
|
});
|
|
1561
|
+
staticCli.command("ls", "List recent deploys for a site").option("--json", "Output as JSON").option("--site <site>", "Override site from platform.yaml").action(async (flags) => {
|
|
1562
|
+
try {
|
|
1563
|
+
await ls({
|
|
1564
|
+
json: flags.json ?? false,
|
|
1565
|
+
site: flags.site
|
|
1566
|
+
});
|
|
1567
|
+
} catch (err) {
|
|
1568
|
+
handleActionError("ls", flags.json ?? false, err);
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
963
1571
|
staticCli.help();
|
|
964
1572
|
staticCli.version(version);
|
|
965
|
-
staticCli.parse(["node", "universe-static", ...
|
|
1573
|
+
staticCli.parse(["node", "universe-static", ...staticArgs]);
|
|
966
1574
|
} else {
|
|
967
1575
|
const cli = cac("universe");
|
|
968
|
-
cli.command("
|
|
969
|
-
|
|
1576
|
+
cli.command("login", "Authenticate with GitHub via OAuth device flow").option("--json", "Output as JSON").option("--force", "Replace any existing stored token").action(async (flags) => {
|
|
1577
|
+
try {
|
|
1578
|
+
await login({
|
|
1579
|
+
json: flags.json ?? false,
|
|
1580
|
+
force: flags.force ?? false
|
|
1581
|
+
});
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
handleActionError("login", flags.json ?? false, err);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
cli.command("logout", "Remove the stored GitHub device-flow token").option("--json", "Output as JSON").action(async (flags) => {
|
|
1587
|
+
try {
|
|
1588
|
+
await logout({ json: flags.json ?? false });
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
handleActionError("logout", flags.json ?? false, err);
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
cli.command("whoami", "Show resolved GitHub identity and authorized sites").option("--json", "Output as JSON").action(async (flags) => {
|
|
1594
|
+
try {
|
|
1595
|
+
await whoami({ json: flags.json ?? false });
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
handleActionError("whoami", flags.json ?? false, err);
|
|
1598
|
+
}
|
|
970
1599
|
});
|
|
1600
|
+
cli.command("static <subcommand>", "Static site deployment commands");
|
|
971
1601
|
cli.help();
|
|
972
1602
|
cli.version(version);
|
|
973
1603
|
cli.parse(argv);
|