@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.
Files changed (4) hide show
  1. package/README.md +88 -19
  2. package/dist/index.cjs +16060 -47360
  3. package/dist/index.js +1422 -792
  4. 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 EXIT_CREDENTIAL = 12;
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 = EXIT_CREDENTIAL;
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
- // src/config/loader.ts
45
- import { readFileSync } from "fs";
46
- import { isAbsolute, relative, resolve, sep } from "path";
47
- import { parse as parseYaml } from "yaml";
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 as execSync2 } from "child_process";
53
+ import { execSync } from "child_process";
454
54
  function getGitState() {
455
55
  try {
456
- const hash = execSync2("git rev-parse HEAD", { encoding: "utf-8" }).trim();
457
- const status = execSync2("git status --porcelain", { encoding: "utf-8" });
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 as relative2, sep as sep2 } from "path";
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 = relative2(base, full);
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 = relative2(baseReal, resolved);
495
- if (rel === ".." || rel.startsWith(`..${sep2}`)) {
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/deploy/preflight.ts
506
- function validateOutputDir(dir) {
507
- if (!existsSync(dir)) {
508
- return { valid: false, fileCount: 0, error: "directory not found" };
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
- if (!statSync2(dir).isDirectory()) {
511
- return { valid: false, fileCount: 0, error: "not a directory" };
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
- let fileCount;
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
- fileCount = walkFiles(dir).length;
185
+ raw = await readFile(tokenPath(), "utf-8");
516
186
  } catch (err) {
517
- if (err instanceof StorageError) {
518
- return { valid: false, fileCount: 0, error: err.message };
519
- }
187
+ if (isFileNotFound(err)) return null;
520
188
  throw err;
521
189
  }
522
- if (fileCount === 0) {
523
- return { valid: false, fileCount: 0, error: "directory is empty" };
524
- }
525
- return { valid: true, fileCount };
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/deploy/upload.ts
529
- import { readFileSync as readFileSync2 } from "fs";
530
- import { extname, basename } from "path";
531
- import { lookup } from "mrmime";
532
- import pLimit from "p-limit";
533
- var FINGERPRINT_RE = /[.-][a-zA-Z0-9_-]{8,}\./;
534
- function getContentType(filename) {
535
- const mime = lookup(filename);
536
- if (mime === void 0) {
537
- console.warn(`Unknown content-type for extension: ${extname(filename)}`);
538
- return "application/octet-stream";
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 getCacheControl(filename) {
543
- if (extname(filename) === ".html") {
544
- return "public, max-age=60, must-revalidate";
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
- if (FINGERPRINT_RE.test(basename(filename))) {
547
- return "public, max-age=31536000, immutable";
231
+ const stored = await loadStored();
232
+ if (isNonEmpty(stored)) {
233
+ return { token: stored.trim(), source: "device_flow" };
548
234
  }
549
- return "public, max-age=3600";
235
+ return null;
550
236
  }
551
- async function uploadDirectory(client, bucket, site, deployId, outputDir, options) {
552
- const concurrency = options?.concurrency ?? 10;
553
- const limit = pLimit(concurrency);
554
- const files = walkFiles(outputDir);
555
- const errors = [];
556
- let totalSize = 0;
557
- let fileCount = 0;
558
- const tasks = files.map(
559
- (file) => limit(async () => {
560
- const key = `${site}/deploys/${deployId}/${file.relPath}`;
561
- const contentType = getContentType(file.relPath);
562
- const cacheControl = getCacheControl(file.relPath);
563
- try {
564
- const body = readFileSync2(file.absPath);
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
- await Promise.all(tasks);
581
- return { fileCount, totalSize, errors };
582
- }
583
-
584
- // src/deploy/metadata.ts
585
- async function writeDeployMetadata(client, bucket, site, deployId, meta) {
586
- const key = `${site}/_universe/deploys/${deployId}.json`;
587
- const body = JSON.stringify(meta);
588
- await putObject(client, {
589
- bucket,
590
- key,
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
- // src/output/format.ts
597
- import { log } from "@clack/prompts";
598
-
599
- // src/output/envelope.ts
600
- function buildEnvelope(command, success, data) {
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
- schemaVersion: "1",
603
- command,
604
- success,
605
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
606
- ...data
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 buildErrorEnvelope(command, code, message, issues) {
610
- const error = {
611
- code,
612
- message
613
- };
614
- if (issues !== void 0) {
615
- error.issues = issues;
616
- }
617
- return {
618
- schemaVersion: "1",
619
- command,
620
- success: false,
621
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
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/output/format.ts
627
- function outputSuccess(ctx, humanMessage, data) {
628
- if (ctx.json) {
629
- const envelope = buildEnvelope(ctx.command, true, data);
630
- process.stdout.write(JSON.stringify(envelope) + "\n");
631
- } else {
632
- log.success(humanMessage);
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 outputError(ctx, code, message, issues) {
636
- const redactedMessage = redact(message);
637
- const redactedIssues = issues?.map(redact);
638
- if (ctx.json) {
639
- const envelope = buildErrorEnvelope(
640
- ctx.command,
641
- code,
642
- redactedMessage,
643
- redactedIssues
644
- );
645
- process.stdout.write(JSON.stringify(envelope) + "\n");
646
- } else {
647
- log.error(redactedMessage);
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/commands/deploy.ts
652
- var MAX_COLLISION_RETRIES = 3;
653
- var COLLISION_DELAY_MS = 1e3;
654
- async function resolveDeployId(client, bucket, site, gitHash, force) {
655
- let lastDeployId = "";
656
- for (let attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
657
- const deployId = generateDeployId(gitHash, force);
658
- lastDeployId = deployId;
659
- const prefix = `${site}/deploys/${deployId}/`;
660
- const existing = await listObjects(client, { bucket, prefix });
661
- if (existing.length === 0) {
662
- return deployId;
663
- }
664
- if (attempt < MAX_COLLISION_RETRIES - 1) {
665
- await new Promise((resolve2) => setTimeout(resolve2, COLLISION_DELAY_MS));
666
- }
667
- }
668
- throw new StorageError(
669
- `Deploy ID collision could not be resolved after ${MAX_COLLISION_RETRIES} attempts (last attempted: ${lastDeployId}). Commit a new change and retry, or wait for the timestamp to advance.`
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
- async function deploy(options) {
673
- const config = loadConfig({
674
- flags: options.outputDir ? { outputDir: options.outputDir } : void 0
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
- const credentials = resolveCredentials({
677
- remoteName: config.static.rclone_remote
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
- const client = createS3Client(credentials);
680
- const bucket = config.static.bucket;
681
- const site = config.name;
682
- const ctx = { json: options.json, command: "deploy" };
683
- const git = getGitState();
684
- if (git.hash === null && !options.force) {
685
- outputError(
686
- ctx,
687
- EXIT_GIT,
688
- "Git hash not available \u2014 use --force to deploy without git info"
689
- );
690
- exitWithCode(
691
- EXIT_GIT,
692
- "Git hash not available \u2014 use --force to deploy without git info"
693
- );
694
- return;
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
- if (git.dirty) {
697
- console.warn(
698
- "Warning: git working tree is dirty \u2014 uncommitted changes will not be reflected in the deploy"
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
- const preflight = validateOutputDir(config.static.output_dir);
702
- if (!preflight.valid) {
703
- outputError(
704
- ctx,
705
- EXIT_OUTPUT_DIR,
706
- `Output directory invalid: ${preflight.error}`
707
- );
708
- exitWithCode(
709
- EXIT_OUTPUT_DIR,
710
- `Output directory invalid: ${preflight.error}`
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
- const gitHash = git.hash ?? void 0;
715
- const deployId = await resolveDeployId(
716
- client,
717
- bucket,
718
- site,
719
- gitHash,
720
- options.force ?? false
721
- );
722
- const uploadResult = await uploadDirectory(
723
- client,
724
- bucket,
725
- site,
726
- deployId,
727
- config.static.output_dir
728
- );
729
- if (uploadResult.errors.length > 0) {
730
- outputError(
731
- ctx,
732
- EXIT_PARTIAL,
733
- `Upload partially failed: ${uploadResult.errors.length} file(s) failed`,
734
- uploadResult.errors
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
- await writeDeployMetadata(client, bucket, site, deployId, {
743
- deployId,
744
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
745
- gitHash: git.hash,
746
- gitDirty: git.dirty,
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/storage/deploys.ts
775
- async function listDeploys(client, bucket, site) {
776
- const prefix = `${site}/deploys/`;
777
- const items = await listObjects(client, { bucket, prefix });
778
- const deployIds = /* @__PURE__ */ new Set();
779
- for (const item of items) {
780
- const afterPrefix = item.key.slice(prefix.length);
781
- const segment = afterPrefix.split("/")[0];
782
- if (segment) {
783
- deployIds.add(segment);
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
- return [...deployIds].sort().reverse();
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
- async function deployExists(client, bucket, site, deployId) {
789
- const prefix = `${site}/deploys/${deployId}/`;
790
- const items = await listObjects(client, { bucket, prefix });
791
- return items.length > 0;
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
- async function promote(options) {
796
- const config = loadConfig();
797
- const credentials = resolveCredentials({
798
- remoteName: config.static.rclone_remote
799
- });
800
- const client = createS3Client(credentials);
801
- const bucket = config.static.bucket;
802
- const site = config.name;
803
- const ctx = { json: options.json, command: "promote" };
804
- let deployId;
805
- if (options.deployId) {
806
- const exists = await deployExists(client, bucket, site, options.deployId);
807
- if (!exists) {
808
- outputError(
809
- ctx,
810
- EXIT_DEPLOY_NOT_FOUND,
811
- `Deploy ${options.deployId} not found`
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
- exitWithCode(
814
- EXIT_DEPLOY_NOT_FOUND,
815
- `Deploy ${options.deployId} not found`
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
- deployId = options.deployId;
820
- } else {
821
- const preview = await readAlias(client, bucket, site, "preview");
822
- if (!preview) {
823
- outputError(
824
- ctx,
825
- EXIT_ALIAS,
826
- "No preview alias set \u2014 deploy first or specify a deploy ID"
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
- exitWithCode(
829
- EXIT_ALIAS,
830
- "No preview alias set \u2014 deploy first or specify a deploy ID"
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
- deployId = preview;
835
- }
836
- await writeAlias(client, bucket, site, "production", deployId);
837
- const productionDomain = config.domain?.production ?? site;
838
- const humanMsg = [
839
- `Promoted ${deployId} to production`,
840
- ``,
841
- ` Site: ${site}`,
842
- ` Deploy: ${deployId}`,
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 { confirm } from "@clack/prompts";
855
- async function rollback(options) {
856
- const config = loadConfig();
857
- const credentials = resolveCredentials({
858
- remoteName: config.static.rclone_remote
859
- });
860
- const client = createS3Client(credentials);
861
- const bucket = config.static.bucket;
862
- const site = config.name;
863
- const ctx = { json: options.json, command: "rollback" };
864
- const currentDeployId = await readAlias(client, bucket, site, "production");
865
- if (!currentDeployId) {
866
- outputError(
867
- ctx,
868
- EXIT_ALIAS,
869
- "No production alias set \u2014 nothing to rollback"
870
- );
871
- exitWithCode(EXIT_ALIAS, "No production alias set \u2014 nothing to rollback");
872
- return;
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 deploys = await listDeploys(client, bucket, site);
875
- const currentIndex = deploys.indexOf(currentDeployId);
876
- const previousDeploy = deploys[currentIndex + 1];
877
- if (!previousDeploy) {
878
- outputError(ctx, EXIT_ALIAS, "no previous deploy to rollback to");
879
- exitWithCode(EXIT_ALIAS, "no previous deploy to rollback to");
880
- return;
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
- if (options.json && !options.confirm) {
883
- outputError(
884
- ctx,
885
- EXIT_CONFIRM,
886
- "Rollback requires --confirm flag in JSON mode"
887
- );
888
- exitWithCode(EXIT_CONFIRM, "Rollback requires --confirm flag in JSON mode");
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
- if (!options.json && !options.confirm) {
892
- const confirmed = await confirm({
893
- message: `Rollback production from ${currentDeployId} to ${previousDeploy}?`
894
- });
895
- if (!confirmed || typeof confirmed === "symbol") {
896
- return;
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
- await writeAlias(client, bucket, site, "production", previousDeploy);
900
- const productionDomain = config.domain?.production ?? site;
901
- const humanMsg = [
902
- `Rolled back production`,
903
- ``,
904
- ` Site: ${site}`,
905
- ` Was: ${currentDeployId}`,
906
- ` Now: ${previousDeploy}`,
907
- ` Production: https://${productionDomain}`
908
- ].join("\n");
909
- outputSuccess(ctx, humanMsg, {
910
- previousDeployId: currentDeployId,
911
- rolledBackTo: previousDeploy,
912
- site,
913
- alias: "production",
914
- productionDomain
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.3.2" : "0.0.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
- if (args[0] === "static") {
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 to S3").option("--json", "Output as JSON").option("--force", "Force deploy without git hash").option("--output-dir <dir>", "Build output directory").action(
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
- force: flags.force ?? false,
937
- outputDir: flags.outputDir
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 [deploy-id]", "Promote a deploy to production").option("--json", "Output as JSON").action(
945
- async (deployId, flags) => {
946
- try {
947
- await promote({ json: flags.json ?? false, deployId });
948
- } catch (err) {
949
- handleActionError("promote", flags.json ?? false, err);
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", "Rollback production to previous deploy").option("--json", "Output as JSON").option("--confirm", "Confirm rollback").action(async (flags) => {
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
- confirm: flags.confirm ?? false
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", ...args.slice(1)]);
1573
+ staticCli.parse(["node", "universe-static", ...staticArgs]);
966
1574
  } else {
967
1575
  const cli = cac("universe");
968
- cli.command("static [...args]", "Static site deployment commands").action(() => {
969
- console.log("Run: universe static --help");
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);