@freecodecamp/universe-cli 0.3.3 → 0.5.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 +130 -45
  2. package/dist/index.cjs +16399 -47284
  3. package/dist/index.js +1844 -793
  4. package/package.json +8 -11
package/dist/index.js CHANGED
@@ -3,21 +3,20 @@
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
- function exitWithCode(code, message) {
18
- if (message !== void 0) {
19
- process.stderr.write(message + "\n");
20
- }
19
+ function exitWithCode(code) {
21
20
  process.exit(code);
22
21
  }
23
22
 
@@ -32,7 +31,7 @@ var ConfigError = class extends CliError {
32
31
  exitCode = EXIT_CONFIG;
33
32
  };
34
33
  var CredentialError = class extends CliError {
35
- exitCode = EXIT_CREDENTIAL;
34
+ exitCode = EXIT_CREDENTIALS;
36
35
  };
37
36
  var StorageError = class extends CliError {
38
37
  exitCode = EXIT_STORAGE;
@@ -40,440 +39,35 @@ var StorageError = class extends CliError {
40
39
  var GitError = class extends CliError {
41
40
  exitCode = EXIT_GIT;
42
41
  };
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
- }
42
+ var PartialUploadError = class extends CliError {
43
+ exitCode = EXIT_PARTIAL;
44
+ };
45
+ var UsageError = class extends CliError {
46
+ exitCode = EXIT_USAGE;
47
+ };
451
48
 
452
49
  // src/deploy/git.ts
453
- import { execSync as execSync2 } from "child_process";
50
+ import { execSync } from "child_process";
454
51
  function getGitState() {
455
52
  try {
456
- const hash = execSync2("git rev-parse HEAD", { encoding: "utf-8" }).trim();
457
- const status = execSync2("git status --porcelain", { encoding: "utf-8" });
53
+ const hash = execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
54
+ const status = execSync("git status --porcelain", { encoding: "utf-8" });
458
55
  return { hash, dirty: status.length > 0 };
459
56
  } catch {
460
57
  return { hash: null, dirty: false, error: "not a git repository" };
461
58
  }
462
59
  }
463
60
 
464
- // src/deploy/preflight.ts
465
- import { existsSync, statSync as statSync2 } from "fs";
466
-
467
61
  // src/deploy/walk.ts
468
62
  import { readdirSync, realpathSync, statSync } from "fs";
469
- import { join, relative as relative2, sep as sep2 } from "path";
63
+ import { join, relative, sep } from "path";
470
64
  function walkFiles(base) {
471
65
  const baseReal = realpathSync(base);
472
66
  const entries = readdirSync(base, { recursive: true, withFileTypes: true });
473
67
  const files = [];
474
68
  for (const entry of entries) {
475
69
  const full = join(entry.parentPath, entry.name);
476
- const relPath = relative2(base, full);
70
+ const relPath = relative(base, full);
477
71
  let targetIsFile;
478
72
  try {
479
73
  targetIsFile = statSync(full).isFile();
@@ -491,8 +85,8 @@ function walkFiles(base) {
491
85
  `"${relPath}" could not be resolved (dangling symlink?)`
492
86
  );
493
87
  }
494
- const rel = relative2(baseReal, resolved);
495
- if (rel === ".." || rel.startsWith(`..${sep2}`)) {
88
+ const rel = relative(baseReal, resolved);
89
+ if (rel === ".." || rel.startsWith(`..${sep}`)) {
496
90
  throw new StorageError(
497
91
  `"${relPath}" resolves outside the output directory (symlink escape). Resolved to: ${resolved}`
498
92
  );
@@ -502,472 +96,1929 @@ function walkFiles(base) {
502
96
  return files;
503
97
  }
504
98
 
505
- // src/deploy/preflight.ts
506
- function validateOutputDir(dir) {
507
- if (!existsSync(dir)) {
508
- return { valid: false, fileCount: 0, error: "directory not found" };
99
+ // src/lib/build.ts
100
+ import { spawn } from "child_process";
101
+ import { stat } from "fs/promises";
102
+ import { isAbsolute, resolve } from "path";
103
+ var defaultExec = async (req) => {
104
+ return new Promise((resolveExit, reject) => {
105
+ const child = spawn(req.command, {
106
+ cwd: req.cwd,
107
+ shell: true,
108
+ stdio: "inherit"
109
+ });
110
+ child.on("error", (err) => reject(err));
111
+ child.on("exit", (code) => resolveExit(code ?? 1));
112
+ });
113
+ };
114
+ async function ensureDirectory(absPath) {
115
+ let st;
116
+ try {
117
+ st = await stat(absPath);
118
+ } catch {
119
+ throw new ConfigError(`output directory missing after build: ${absPath}`);
120
+ }
121
+ if (!st.isDirectory()) {
122
+ throw new ConfigError(
123
+ `output path is not a directory after build: ${absPath}`
124
+ );
125
+ }
126
+ }
127
+ async function runBuild(options, deps = {}) {
128
+ const exec = deps.exec ?? defaultExec;
129
+ const absCwd = isAbsolute(options.cwd) ? options.cwd : resolve(options.cwd);
130
+ const absOutput = isAbsolute(options.outputDir) ? options.outputDir : resolve(absCwd, options.outputDir);
131
+ if (!options.command) {
132
+ await ensureDirectory(absOutput);
133
+ return { skipped: true, outputDir: absOutput };
134
+ }
135
+ const code = await exec({ command: options.command, cwd: absCwd });
136
+ if (code !== 0) {
137
+ throw new ConfigError(
138
+ `build command failed with exit code ${code}: ${options.command}`
139
+ );
509
140
  }
510
- if (!statSync2(dir).isDirectory()) {
511
- return { valid: false, fileCount: 0, error: "not a directory" };
141
+ await ensureDirectory(absOutput);
142
+ return { skipped: false, outputDir: absOutput };
143
+ }
144
+
145
+ // src/lib/constants.ts
146
+ var DEFAULT_GH_CLIENT_ID = "Iv23liIuGmZRyPd5wUeN";
147
+ var DEFAULT_PROXY_URL = "https://uploads.freecode.camp";
148
+
149
+ // src/lib/identity.ts
150
+ import { execFile } from "child_process";
151
+ import { promisify } from "util";
152
+
153
+ // src/lib/token-store.ts
154
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
155
+ import { homedir } from "os";
156
+ import { dirname, join as join2 } from "path";
157
+ var APP_DIR = "universe-cli";
158
+ var TOKEN_FILE = "token";
159
+ function configBase() {
160
+ const xdg = process.env["XDG_CONFIG_HOME"];
161
+ if (xdg && xdg.length > 0) return xdg;
162
+ return join2(homedir(), ".config");
163
+ }
164
+ function tokenPath() {
165
+ return join2(configBase(), APP_DIR, TOKEN_FILE);
166
+ }
167
+ async function saveToken(token) {
168
+ const trimmed = token.trim();
169
+ if (trimmed.length === 0) {
170
+ throw new Error("refusing to save empty token");
512
171
  }
513
- let fileCount;
172
+ const path = tokenPath();
173
+ const dir = dirname(path);
174
+ await mkdir(dir, { recursive: true, mode: 448 });
175
+ await chmod(dir, 448);
176
+ await writeFile(path, trimmed, { mode: 384 });
177
+ await chmod(path, 384);
178
+ }
179
+ async function loadToken() {
180
+ let raw;
514
181
  try {
515
- fileCount = walkFiles(dir).length;
182
+ raw = await readFile(tokenPath(), "utf-8");
516
183
  } catch (err) {
517
- if (err instanceof StorageError) {
518
- return { valid: false, fileCount: 0, error: err.message };
519
- }
184
+ if (isFileNotFound(err)) return null;
520
185
  throw err;
521
186
  }
522
- if (fileCount === 0) {
523
- return { valid: false, fileCount: 0, error: "directory is empty" };
524
- }
525
- return { valid: true, fileCount };
187
+ const trimmed = raw.trim();
188
+ return trimmed.length === 0 ? null : trimmed;
189
+ }
190
+ async function deleteToken() {
191
+ await rm(tokenPath(), { force: true });
192
+ }
193
+ function isFileNotFound(err) {
194
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
526
195
  }
527
196
 
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";
197
+ // src/lib/identity.ts
198
+ var execFileP = promisify(execFile);
199
+ function isNonEmpty(s) {
200
+ return typeof s === "string" && s.trim().length > 0;
201
+ }
202
+ async function defaultExecGhAuthToken() {
203
+ try {
204
+ const { stdout } = await execFileP("gh", ["auth", "token"], {
205
+ timeout: 5e3
206
+ });
207
+ return stdout;
208
+ } catch {
209
+ return null;
539
210
  }
540
- return mime;
541
211
  }
542
- function getCacheControl(filename) {
543
- if (extname(filename) === ".html") {
544
- return "public, max-age=60, must-revalidate";
212
+ async function resolveIdentity(opts = {}) {
213
+ const env = opts.env ?? process.env;
214
+ const execGh = opts.execGhAuthToken ?? defaultExecGhAuthToken;
215
+ const loadStored = opts.loadStoredToken ?? loadToken;
216
+ const ghEnv = env["GITHUB_TOKEN"];
217
+ if (isNonEmpty(ghEnv)) {
218
+ return { token: ghEnv.trim(), source: "env_GITHUB_TOKEN" };
545
219
  }
546
- if (FINGERPRINT_RE.test(basename(filename))) {
547
- return "public, max-age=31536000, immutable";
220
+ const ghTokenEnv = env["GH_TOKEN"];
221
+ if (isNonEmpty(ghTokenEnv)) {
222
+ return { token: ghTokenEnv.trim(), source: "env_GH_TOKEN" };
548
223
  }
549
- return "public, max-age=3600";
224
+ const ghCli = await execGh();
225
+ if (isNonEmpty(ghCli)) {
226
+ return { token: ghCli.trim(), source: "gh_cli" };
227
+ }
228
+ const stored = await loadStored();
229
+ if (isNonEmpty(stored)) {
230
+ return { token: stored.trim(), source: "device_flow" };
231
+ }
232
+ return null;
550
233
  }
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}`);
234
+
235
+ // src/lib/ignore.ts
236
+ var SPECIAL = /[.+^${}()|[\]\\]/g;
237
+ function globToRegexBody(pattern) {
238
+ let out = "";
239
+ let i = 0;
240
+ while (i < pattern.length) {
241
+ const ch = pattern[i] ?? "";
242
+ if (ch === "*") {
243
+ const next = pattern[i + 1];
244
+ if (next === "*") {
245
+ out += ".*";
246
+ i += 2;
247
+ continue;
577
248
  }
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
- });
249
+ out += "[^/]*";
250
+ i += 1;
251
+ continue;
252
+ }
253
+ if (ch === "?") {
254
+ out += "[^/]";
255
+ i += 1;
256
+ continue;
257
+ }
258
+ out += ch.replace(SPECIAL, "\\$&");
259
+ i += 1;
260
+ }
261
+ return out;
594
262
  }
595
-
596
- // src/output/format.ts
597
- import { log } from "@clack/prompts";
598
-
599
- // src/output/envelope.ts
600
- function buildEnvelope(command, success, data) {
263
+ function compilePattern(pattern) {
264
+ const anchored = pattern.includes("/");
265
+ const body = globToRegexBody(pattern);
266
+ if (anchored) {
267
+ const re2 = new RegExp(`^${body}$`);
268
+ return { test: (rel) => re2.test(rel) };
269
+ }
270
+ const re = new RegExp(`^${body}$`);
601
271
  return {
602
- schemaVersion: "1",
603
- command,
604
- success,
605
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
606
- ...data
272
+ test: (rel) => {
273
+ const idx = rel.lastIndexOf("/");
274
+ const base = idx === -1 ? rel : rel.slice(idx + 1);
275
+ return re.test(base);
276
+ }
607
277
  };
608
278
  }
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
279
+ function normalize(rel) {
280
+ let out = rel.replace(/\\/g, "/");
281
+ if (out.startsWith("./")) out = out.slice(2);
282
+ return out;
283
+ }
284
+ function createIgnoreFilter(patterns) {
285
+ const compiled = patterns.filter((p) => p.length > 0).map(compilePattern);
286
+ return (relPath) => {
287
+ const norm = normalize(relPath);
288
+ for (const c of compiled) {
289
+ if (c.test(norm)) return true;
290
+ }
291
+ return false;
623
292
  };
624
293
  }
625
294
 
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);
633
- }
634
- }
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);
295
+ // src/lib/platform-yaml.ts
296
+ import { parse as parseYaml } from "yaml";
297
+
298
+ // src/lib/platform-yaml.schema.ts
299
+ import { z } from "zod";
300
+ var SITE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
301
+ var siteName = z.string().min(1, "site is required").max(63, "site must be at most 63 characters").regex(
302
+ SITE_NAME_PATTERN,
303
+ "site must be lowercase letters, digits, and single hyphens; no leading/trailing/consecutive hyphens"
304
+ );
305
+ var buildSchema = z.object({
306
+ command: z.string().min(1).optional(),
307
+ output: z.string().min(1).default("dist")
308
+ }).strict();
309
+ var DEFAULT_DEPLOY_IGNORE = Object.freeze([
310
+ "*.map",
311
+ "node_modules/**",
312
+ ".git/**",
313
+ ".env*"
314
+ ]);
315
+ var deploySchema = z.object({
316
+ preview: z.boolean().default(true),
317
+ ignore: z.array(z.string()).default([...DEFAULT_DEPLOY_IGNORE])
318
+ }).strict().prefault({});
319
+ var platformYamlSchemaV2 = z.object({
320
+ site: siteName,
321
+ build: buildSchema.prefault({}),
322
+ deploy: deploySchema
323
+ }).strict();
324
+
325
+ // src/lib/platform-yaml.ts
326
+ var V1_MARKERS = ["r2", "stack", "domain", "static", "name"];
327
+ 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.";
328
+ function isPlainObject(value) {
329
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
330
+ }
331
+ function detectV1(parsed) {
332
+ for (const marker of V1_MARKERS) {
333
+ if (Object.prototype.hasOwnProperty.call(parsed, marker)) {
334
+ return marker;
335
+ }
648
336
  }
337
+ return void 0;
338
+ }
339
+ function formatZodIssues(issues) {
340
+ return issues.map((issue) => {
341
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
342
+ return ` - ${path}: ${issue.message}`;
343
+ }).join("\n");
344
+ }
345
+ function parsePlatformYaml(text) {
346
+ let parsed;
347
+ try {
348
+ parsed = parseYaml(text);
349
+ } catch (err) {
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ return { ok: false, error: `platform.yaml is not valid YAML: ${message}` };
352
+ }
353
+ if (parsed === null || parsed === void 0) {
354
+ return {
355
+ ok: false,
356
+ error: "platform.yaml is empty. Required field: `site`. See docs/platform-yaml.md."
357
+ };
358
+ }
359
+ if (!isPlainObject(parsed)) {
360
+ return {
361
+ ok: false,
362
+ error: "platform.yaml must be a YAML mapping at the root. See docs/platform-yaml.md."
363
+ };
364
+ }
365
+ const v1Marker = detectV1(parsed);
366
+ if (v1Marker) {
367
+ return {
368
+ ok: false,
369
+ error: `${MIGRATION_HINT} (v1 marker detected: \`${v1Marker}\`)`
370
+ };
371
+ }
372
+ const result = platformYamlSchemaV2.safeParse(parsed);
373
+ if (!result.success) {
374
+ return {
375
+ ok: false,
376
+ error: `platform.yaml is invalid:
377
+ ${formatZodIssues(result.error.issues)}
378
+ See docs/platform-yaml.md.`
379
+ };
380
+ }
381
+ return { ok: true, value: result.data };
649
382
  }
650
383
 
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.`
384
+ // src/lib/similarity.ts
385
+ function editDistance(a, b) {
386
+ const m = a.length;
387
+ const n = b.length;
388
+ if (m === 0) return n;
389
+ if (n === 0) return m;
390
+ const d = Array.from(
391
+ { length: m + 1 },
392
+ () => new Array(n + 1).fill(0)
670
393
  );
394
+ for (let i = 0; i <= m; i++) d[i][0] = i;
395
+ for (let j = 0; j <= n; j++) d[0][j] = j;
396
+ for (let i = 1; i <= m; i++) {
397
+ for (let j = 1; j <= n; j++) {
398
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
399
+ d[i][j] = Math.min(
400
+ d[i - 1][j] + 1,
401
+ d[i][j - 1] + 1,
402
+ d[i - 1][j - 1] + cost
403
+ );
404
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
405
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
406
+ }
407
+ }
408
+ }
409
+ return d[m][n];
671
410
  }
672
- async function deploy(options) {
673
- const config = loadConfig({
674
- flags: options.outputDir ? { outputDir: options.outputDir } : void 0
675
- });
676
- const credentials = resolveCredentials({
677
- remoteName: config.static.rclone_remote
411
+ function suggest(target, candidates, threshold = 2) {
412
+ if (candidates.length === 0) return null;
413
+ const lc = target.toLowerCase();
414
+ const sub = candidates.find((c) => {
415
+ const clc = c.toLowerCase();
416
+ return clc.includes(lc) || lc.includes(clc);
678
417
  });
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;
418
+ if (sub) return sub;
419
+ let best = null;
420
+ let bestD = threshold + 1;
421
+ for (const c of candidates) {
422
+ const d = editDistance(lc, c.toLowerCase());
423
+ if (d < bestD) {
424
+ bestD = d;
425
+ best = c;
426
+ }
695
427
  }
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
- );
428
+ return bestD <= threshold ? best : null;
429
+ }
430
+
431
+ // src/lib/proxy-client.ts
432
+ var ProxyError = class extends CliError {
433
+ exitCode;
434
+ status;
435
+ code;
436
+ constructor(status, code, message) {
437
+ super(message);
438
+ this.status = status;
439
+ this.code = code;
440
+ this.exitCode = mapExitCode(status);
441
+ }
442
+ };
443
+ function mapExitCode(status) {
444
+ if (status === 401 || status === 403) return EXIT_CREDENTIALS;
445
+ if (status === 422 || status === 0 || status >= 500) return EXIT_STORAGE;
446
+ return EXIT_USAGE;
447
+ }
448
+ function wrapProxyError(command, err) {
449
+ if (err instanceof ProxyError) {
450
+ return {
451
+ code: err.exitCode,
452
+ message: `${command} failed (${err.code}): ${err.message}`
453
+ };
454
+ }
455
+ if (err instanceof CliError) {
456
+ return { code: err.exitCode, message: err.message };
457
+ }
458
+ if (err instanceof Error) {
459
+ return { code: EXIT_USAGE, message: err.message };
460
+ }
461
+ return { code: EXIT_USAGE, message: String(err) };
462
+ }
463
+ function isProxyErrorEnvelope(value) {
464
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "error" in value;
465
+ }
466
+ async function readErrorEnvelope(response) {
467
+ const status = response.status;
468
+ let raw;
469
+ try {
470
+ raw = await response.json();
471
+ } catch {
472
+ return {
473
+ code: `http_${status}`,
474
+ message: response.statusText || "request failed"
475
+ };
476
+ }
477
+ if (isProxyErrorEnvelope(raw) && raw.error) {
478
+ return {
479
+ code: raw.error.code ?? `http_${status}`,
480
+ message: raw.error.message ?? response.statusText ?? "request failed"
481
+ };
482
+ }
483
+ return {
484
+ code: `http_${status}`,
485
+ message: response.statusText || "request failed"
486
+ };
487
+ }
488
+ function stripTrailingSlash(url) {
489
+ return url.endsWith("/") ? url.slice(0, -1) : url;
490
+ }
491
+ function createProxyClient(cfg) {
492
+ const base = stripTrailingSlash(cfg.baseUrl);
493
+ const fetchImpl = cfg.fetch ?? globalThis.fetch.bind(globalThis);
494
+ async function userBearer() {
495
+ const tok = await cfg.getAuthToken();
496
+ return `Bearer ${tok}`;
497
+ }
498
+ async function call(url, init) {
499
+ let response;
500
+ try {
501
+ response = await fetchImpl(url, init);
502
+ } catch (err) {
503
+ const message = err instanceof Error ? err.message : String(err);
504
+ throw new ProxyError(0, "network_error", `proxy unreachable: ${message}`);
505
+ }
506
+ if (!response.ok) {
507
+ const env = await readErrorEnvelope(response);
508
+ throw new ProxyError(response.status, env.code, env.message);
509
+ }
510
+ if (response.status === 204) {
511
+ return void 0;
512
+ }
513
+ return await response.json();
700
514
  }
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}`
515
+ return {
516
+ async whoami() {
517
+ return call(`${base}/api/whoami`, {
518
+ method: "GET",
519
+ headers: {
520
+ Authorization: await userBearer(),
521
+ Accept: "application/json"
522
+ }
523
+ });
524
+ },
525
+ async deployInit(req) {
526
+ return call(`${base}/api/deploy/init`, {
527
+ method: "POST",
528
+ headers: {
529
+ Authorization: await userBearer(),
530
+ Accept: "application/json",
531
+ "Content-Type": "application/json"
532
+ },
533
+ body: JSON.stringify(req)
534
+ });
535
+ },
536
+ async deployUpload(req) {
537
+ const url = `${base}/api/deploy/${encodeURIComponent(
538
+ req.deployId
539
+ )}/upload?path=${encodeURIComponent(req.path)}`;
540
+ return call(url, {
541
+ method: "PUT",
542
+ headers: {
543
+ Authorization: `Bearer ${req.jwt}`,
544
+ Accept: "application/json",
545
+ "Content-Type": req.contentType ?? "application/octet-stream"
546
+ },
547
+ body: req.body
548
+ });
549
+ },
550
+ async deployFinalize(req) {
551
+ const url = `${base}/api/deploy/${encodeURIComponent(req.deployId)}/finalize`;
552
+ return call(url, {
553
+ method: "POST",
554
+ headers: {
555
+ Authorization: `Bearer ${req.jwt}`,
556
+ Accept: "application/json",
557
+ "Content-Type": "application/json"
558
+ },
559
+ body: JSON.stringify({ mode: req.mode, files: req.files })
560
+ });
561
+ },
562
+ async siteDeploys(req) {
563
+ const url = `${base}/api/site/${encodeURIComponent(req.site)}/deploys`;
564
+ return call(url, {
565
+ method: "GET",
566
+ headers: {
567
+ Authorization: await userBearer(),
568
+ Accept: "application/json"
569
+ }
570
+ });
571
+ },
572
+ async sitePromote(req) {
573
+ const url = `${base}/api/site/${encodeURIComponent(req.site)}/promote`;
574
+ return call(url, {
575
+ method: "POST",
576
+ headers: {
577
+ Authorization: await userBearer(),
578
+ Accept: "application/json"
579
+ }
580
+ });
581
+ },
582
+ async siteRollback(req) {
583
+ const url = `${base}/api/site/${encodeURIComponent(req.site)}/rollback`;
584
+ return call(url, {
585
+ method: "POST",
586
+ headers: {
587
+ Authorization: await userBearer(),
588
+ Accept: "application/json",
589
+ "Content-Type": "application/json"
590
+ },
591
+ body: JSON.stringify({ to: req.to })
592
+ });
593
+ },
594
+ async registerSite(req) {
595
+ const body = { slug: req.slug };
596
+ if (req.teams && req.teams.length > 0) {
597
+ body.teams = req.teams;
598
+ }
599
+ return call(`${base}/api/site/register`, {
600
+ method: "POST",
601
+ headers: {
602
+ Authorization: await userBearer(),
603
+ Accept: "application/json",
604
+ "Content-Type": "application/json"
605
+ },
606
+ body: JSON.stringify(body)
607
+ });
608
+ },
609
+ async listSites() {
610
+ return call(`${base}/api/sites`, {
611
+ method: "GET",
612
+ headers: {
613
+ Authorization: await userBearer(),
614
+ Accept: "application/json"
615
+ }
616
+ });
617
+ },
618
+ async updateSite(req) {
619
+ const url = `${base}/api/site/${encodeURIComponent(req.slug)}`;
620
+ return call(url, {
621
+ method: "PATCH",
622
+ headers: {
623
+ Authorization: await userBearer(),
624
+ Accept: "application/json",
625
+ "Content-Type": "application/json"
626
+ },
627
+ body: JSON.stringify({ teams: req.teams })
628
+ });
629
+ },
630
+ async deleteSite(req) {
631
+ const url = `${base}/api/site/${encodeURIComponent(req.slug)}`;
632
+ return call(url, {
633
+ method: "DELETE",
634
+ headers: {
635
+ Authorization: await userBearer(),
636
+ Accept: "application/json"
637
+ }
638
+ });
639
+ }
640
+ };
641
+ }
642
+
643
+ // src/lib/upload.ts
644
+ import { readFile as defaultReadFile } from "fs/promises";
645
+ var DEFAULT_CONCURRENCY = 6;
646
+ var MIME_BY_EXT = Object.freeze({
647
+ // text
648
+ html: "text/html",
649
+ htm: "text/html",
650
+ css: "text/css",
651
+ js: "text/javascript",
652
+ mjs: "text/javascript",
653
+ cjs: "text/javascript",
654
+ json: "application/json",
655
+ txt: "text/plain",
656
+ md: "text/markdown",
657
+ xml: "application/xml",
658
+ csv: "text/csv",
659
+ // images
660
+ svg: "image/svg+xml",
661
+ png: "image/png",
662
+ jpg: "image/jpeg",
663
+ jpeg: "image/jpeg",
664
+ gif: "image/gif",
665
+ webp: "image/webp",
666
+ avif: "image/avif",
667
+ ico: "image/x-icon",
668
+ bmp: "image/bmp",
669
+ // fonts
670
+ woff: "font/woff",
671
+ woff2: "font/woff2",
672
+ ttf: "font/ttf",
673
+ otf: "font/otf",
674
+ eot: "application/vnd.ms-fontobject",
675
+ // a/v
676
+ mp4: "video/mp4",
677
+ webm: "video/webm",
678
+ ogg: "audio/ogg",
679
+ mp3: "audio/mpeg",
680
+ wav: "audio/wav",
681
+ // other
682
+ pdf: "application/pdf",
683
+ wasm: "application/wasm"
684
+ });
685
+ function getContentType(filename) {
686
+ const dot = filename.lastIndexOf(".");
687
+ if (dot < 0 || dot === filename.length - 1) {
688
+ return "application/octet-stream";
689
+ }
690
+ const ext = filename.slice(dot + 1).toLowerCase();
691
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
692
+ }
693
+ function createLimit(max) {
694
+ let active = 0;
695
+ const queue = [];
696
+ const acquire = () => {
697
+ if (active < max) {
698
+ active += 1;
699
+ return Promise.resolve();
700
+ }
701
+ return new Promise((resolve6) => {
702
+ queue.push(() => {
703
+ active += 1;
704
+ resolve6();
705
+ });
706
+ });
707
+ };
708
+ const release = () => {
709
+ active -= 1;
710
+ const next = queue.shift();
711
+ if (next) next();
712
+ };
713
+ return async (fn) => {
714
+ await acquire();
715
+ try {
716
+ return await fn();
717
+ } finally {
718
+ release();
719
+ }
720
+ };
721
+ }
722
+ async function uploadFiles(options, deps = {}) {
723
+ const read = deps.readFile ?? defaultReadFile;
724
+ const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
725
+ const limit = createLimit(concurrency);
726
+ const total = options.files.length;
727
+ const uploaded = [];
728
+ const errors = [];
729
+ let totalSize = 0;
730
+ let done = 0;
731
+ const tasks = options.files.map(
732
+ (file) => limit(async () => {
733
+ try {
734
+ const body = await read(file.absPath);
735
+ const bodyAsBodyInit = body;
736
+ await options.client.deployUpload({
737
+ deployId: options.deployId,
738
+ jwt: options.jwt,
739
+ path: file.relPath,
740
+ body: bodyAsBodyInit,
741
+ contentType: getContentType(file.relPath)
742
+ });
743
+ uploaded.push(file.relPath);
744
+ totalSize += body.byteLength;
745
+ } catch (err) {
746
+ const message = err instanceof Error ? err.message : "unknown upload error";
747
+ errors.push(`${file.relPath}: ${message}`);
748
+ } finally {
749
+ done += 1;
750
+ if (options.onProgress) {
751
+ options.onProgress({
752
+ uploaded: done,
753
+ total,
754
+ current: file.relPath
755
+ });
756
+ }
757
+ }
758
+ })
759
+ );
760
+ await Promise.all(tasks);
761
+ return {
762
+ fileCount: uploaded.length,
763
+ totalSize,
764
+ uploaded,
765
+ errors
766
+ };
767
+ }
768
+
769
+ // src/output/envelope.ts
770
+ function buildEnvelope(command, success, data) {
771
+ return {
772
+ schemaVersion: "1",
773
+ command,
774
+ success,
775
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
776
+ ...data
777
+ };
778
+ }
779
+ function buildErrorEnvelope(command, code, message, issues) {
780
+ const error = {
781
+ code,
782
+ message
783
+ };
784
+ if (issues !== void 0) {
785
+ error.issues = issues;
786
+ }
787
+ return {
788
+ schemaVersion: "1",
789
+ command,
790
+ success: false,
791
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
792
+ error
793
+ };
794
+ }
795
+
796
+ // src/commands/deploy.ts
797
+ var defaultReadPlatformYaml = async (cwd) => {
798
+ return readFile2(resolve2(cwd, "platform.yaml"), "utf-8");
799
+ };
800
+ function emitJson(envelope) {
801
+ process.stdout.write(JSON.stringify(envelope) + "\n");
802
+ }
803
+ async function readAndParseConfig(cwd, read) {
804
+ let raw;
805
+ try {
806
+ raw = await read(cwd);
807
+ } catch (err) {
808
+ if (err instanceof Error && err.code === "ENOENT") {
809
+ throw new ConfigError(
810
+ `platform.yaml not found in ${cwd}. See docs/platform-yaml.md.`
811
+ );
812
+ }
813
+ throw err;
814
+ }
815
+ const r = parsePlatformYaml(raw);
816
+ if (!r.ok) throw new ConfigError(r.error);
817
+ return r.value;
818
+ }
819
+ function syntheticSha() {
820
+ return `nogit-${Date.now().toString(36)}`;
821
+ }
822
+ function rethrowProxy(prefix, err) {
823
+ if (err instanceof ProxyError) {
824
+ throw new ProxyError(
825
+ err.status,
826
+ err.code,
827
+ `${prefix} (${err.code}): ${err.message}`
707
828
  );
708
- exitWithCode(
709
- EXIT_OUTPUT_DIR,
710
- `Output directory invalid: ${preflight.error}`
829
+ }
830
+ if (err instanceof Error) throw new StorageError(`${prefix}: ${err.message}`);
831
+ throw new StorageError(`${prefix}: ${String(err)}`);
832
+ }
833
+ var PREFLIGHT_INLINE_LIST_CAP = 10;
834
+ function formatUnauthorizedSiteError(a) {
835
+ const lines = [
836
+ `Site '${a.attempted}' is not registered for your GitHub identity.`,
837
+ ``,
838
+ ` You are: ${a.login}`,
839
+ ``
840
+ ];
841
+ if (a.authorized.length === 0) {
842
+ lines.push(
843
+ ` Your identity is authorized for no sites yet.`,
844
+ ``,
845
+ ` Likely causes:`,
846
+ ` 1. The '${a.attempted}' slug is not registered.`,
847
+ ` Admin (staff): universe sites register ${a.attempted} --team <team>`,
848
+ ` 2. You are not in any team listed on any registered site.`,
849
+ ` Admin (staff): universe sites update <slug> --team +<your-team>`
711
850
  );
712
- return;
851
+ return lines.join("\n");
713
852
  }
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
853
+ const hint = suggest(a.attempted, a.authorized);
854
+ if (hint) {
855
+ lines.push(` Did you mean: ${hint}?`, ``);
856
+ }
857
+ lines.push(
858
+ ` Likely causes (most common first):`,
859
+ ` 1. Typo in platform.yaml \`site:\` \u2014 check the spelling above.`,
860
+ ` 2. The '${a.attempted}' slug is not registered yet.`,
861
+ ` Admin (staff): universe sites register ${a.attempted} --team <team>`,
862
+ ` 3. You are not in any team authorized for '${a.attempted}'.`,
863
+ ` Admin (staff): universe sites update ${a.attempted} --team +<your-team>`,
864
+ ``
728
865
  );
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
866
+ if (a.authorized.length <= PREFLIGHT_INLINE_LIST_CAP) {
867
+ lines.push(
868
+ ` Your authorized sites (${a.authorized.length}):`,
869
+ ...[...a.authorized].sort().map((s) => ` - ${s}`)
735
870
  );
736
- exitWithCode(
737
- EXIT_PARTIAL,
738
- `Upload partially failed: ${uploadResult.errors.length} file(s) failed`
871
+ } else {
872
+ lines.push(
873
+ ` You have ${a.authorized.length} authorized sites \u2014 too many to inline.`,
874
+ ` Run \`universe sites ls --mine\` to inspect the full list.`
739
875
  );
740
- return;
741
876
  }
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
877
+ return lines.join("\n");
878
+ }
879
+ async function deploy(options, deps = {}) {
880
+ const cwd = deps.cwd ?? process.cwd();
881
+ const env = deps.env ?? process.env;
882
+ const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml;
883
+ const resolveId = deps.resolveIdentity ?? resolveIdentity;
884
+ const mkClient = deps.createProxyClient ?? createProxyClient;
885
+ const gitState = deps.getGitState ?? getGitState;
886
+ const build = deps.runBuild ?? runBuild;
887
+ const walk = deps.walkFiles ?? walkFiles;
888
+ const upload = deps.uploadFiles ?? uploadFiles;
889
+ const success = deps.logSuccess ?? ((s) => log.success(s));
890
+ const info = deps.logInfo ?? ((s) => log.info(s));
891
+ const warn = deps.logWarn ?? ((s) => log.warn(s));
892
+ const error = deps.logError ?? ((s) => log.error(s));
893
+ const exit = deps.exit ?? exitWithCode;
894
+ try {
895
+ const identity = await resolveId({ env });
896
+ if (!identity) {
897
+ throw new CredentialError(
898
+ "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
899
+ );
900
+ }
901
+ const config = await readAndParseConfig(cwd, readYaml);
902
+ const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
903
+ const client = mkClient({
904
+ baseUrl,
905
+ getAuthToken: () => identity.token
906
+ });
907
+ let me;
908
+ try {
909
+ me = await client.whoami();
910
+ } catch (err) {
911
+ rethrowProxy("whoami preflight failed", err);
912
+ }
913
+ if (!me.authorizedSites.includes(config.site)) {
914
+ throw new CredentialError(
915
+ formatUnauthorizedSiteError({
916
+ attempted: config.site,
917
+ login: me.login,
918
+ authorized: me.authorizedSites
919
+ })
920
+ );
921
+ }
922
+ const git = gitState();
923
+ if (git.dirty) {
924
+ warn(
925
+ "git working tree is dirty \u2014 uncommitted changes will not be reflected."
926
+ );
927
+ }
928
+ const sha = git.hash ?? syntheticSha();
929
+ const outputDir = options.dir ?? config.build.output;
930
+ const buildResult = await build({
931
+ command: config.build.command,
932
+ cwd,
933
+ outputDir
934
+ });
935
+ if (buildResult.skipped) {
936
+ info("build.command not set \u2014 using pre-built output.");
937
+ }
938
+ const resolvedOutputDir = buildResult.outputDir;
939
+ const walked = walk(resolvedOutputDir);
940
+ const ignore = createIgnoreFilter(config.deploy.ignore);
941
+ const filtered = walked.filter((f) => !ignore(f.relPath));
942
+ if (filtered.length === 0) {
943
+ throw new GitError(`No files to deploy under ${resolvedOutputDir}.`);
944
+ }
945
+ const fileList = filtered.map((f) => f.relPath);
946
+ let initResult;
947
+ try {
948
+ initResult = await client.deployInit({
949
+ site: config.site,
950
+ sha,
951
+ files: fileList
952
+ });
953
+ } catch (err) {
954
+ rethrowProxy("deploy init failed", err);
955
+ }
956
+ const uploadResult = await upload({
957
+ client,
958
+ deployId: initResult.deployId,
959
+ jwt: initResult.jwt,
960
+ files: filtered
961
+ });
962
+ if (uploadResult.errors.length > 0) {
963
+ const message = `Upload partially failed: ${uploadResult.errors.length} file(s) failed:
964
+ - ${uploadResult.errors.join("\n - ")}`;
965
+ throw new PartialUploadError(message);
966
+ }
967
+ const mode = options.promote ? "production" : "preview";
968
+ let finalizeResult;
969
+ try {
970
+ finalizeResult = await client.deployFinalize({
971
+ deployId: initResult.deployId,
972
+ jwt: initResult.jwt,
973
+ mode,
974
+ files: fileList
975
+ });
976
+ } catch (err) {
977
+ rethrowProxy("deploy finalize failed", err);
978
+ }
979
+ if (options.json) {
980
+ emitJson(
981
+ buildEnvelope("deploy", true, {
982
+ deployId: finalizeResult.deployId,
983
+ url: finalizeResult.url,
984
+ mode: finalizeResult.mode,
985
+ site: config.site,
986
+ sha,
987
+ fileCount: uploadResult.fileCount,
988
+ totalSize: uploadResult.totalSize,
989
+ identitySource: identity.source
990
+ })
991
+ );
992
+ } else {
993
+ const sizeKB = (uploadResult.totalSize / 1024).toFixed(1);
994
+ const nextLine = mode === "preview" ? "Next: universe static promote" : "Promoted to production.";
995
+ success(
996
+ [
997
+ `Deployed ${finalizeResult.deployId}`,
998
+ ``,
999
+ ` Site: ${config.site}`,
1000
+ ` Files: ${uploadResult.fileCount}`,
1001
+ ` Size: ${sizeKB} KB`,
1002
+ ` Mode: ${mode}`,
1003
+ ` URL: ${finalizeResult.url}`,
1004
+ ``,
1005
+ nextLine
1006
+ ].join("\n")
1007
+ );
1008
+ }
1009
+ } catch (err) {
1010
+ let code;
1011
+ let message;
1012
+ if (err instanceof ProxyError) {
1013
+ code = err.exitCode;
1014
+ message = err.message;
1015
+ } else if (err instanceof CliError) {
1016
+ code = err.exitCode;
1017
+ message = err.message;
1018
+ } else if (err instanceof Error) {
1019
+ code = EXIT_USAGE;
1020
+ message = err.message;
1021
+ } else {
1022
+ code = EXIT_USAGE;
1023
+ message = String(err);
1024
+ }
1025
+ if (options.json) {
1026
+ emitJson(buildErrorEnvelope("deploy", code, message));
1027
+ } else {
1028
+ error(message);
1029
+ }
1030
+ exit(code);
1031
+ }
1032
+ }
1033
+
1034
+ // src/commands/login.ts
1035
+ import { log as log2 } from "@clack/prompts";
1036
+
1037
+ // src/lib/device-flow.ts
1038
+ var DEVICE_CODE_URL = "https://github.com/login/device/code";
1039
+ var ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
1040
+ var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
1041
+ function isAccessTokenSuccess(body) {
1042
+ return "access_token" in body && typeof body.access_token === "string";
1043
+ }
1044
+ var defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
1045
+ async function runDeviceFlow(opts) {
1046
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
1047
+ const sleep = opts.sleep ?? defaultSleep;
1048
+ const startResp = await fetchImpl(DEVICE_CODE_URL, {
1049
+ method: "POST",
1050
+ headers: {
1051
+ Accept: "application/json",
1052
+ "Content-Type": "application/json"
1053
+ },
1054
+ body: JSON.stringify({
1055
+ client_id: opts.clientId,
1056
+ ...opts.scope ? { scope: opts.scope } : {}
1057
+ })
749
1058
  });
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
1059
+ if (!startResp.ok) {
1060
+ throw new Error(`device code request failed: HTTP ${startResp.status}`);
1061
+ }
1062
+ const start = await startResp.json();
1063
+ await opts.onPrompt({
1064
+ userCode: start.user_code,
1065
+ verificationUri: start.verification_uri,
1066
+ expiresIn: start.expires_in
771
1067
  });
1068
+ let intervalSec = start.interval > 0 ? start.interval : 5;
1069
+ while (true) {
1070
+ await sleep(intervalSec * 1e3);
1071
+ const pollResp = await fetchImpl(ACCESS_TOKEN_URL, {
1072
+ method: "POST",
1073
+ headers: {
1074
+ Accept: "application/json",
1075
+ "Content-Type": "application/json"
1076
+ },
1077
+ body: JSON.stringify({
1078
+ client_id: opts.clientId,
1079
+ device_code: start.device_code,
1080
+ grant_type: DEVICE_CODE_GRANT
1081
+ })
1082
+ });
1083
+ if (!pollResp.ok) {
1084
+ throw new Error(`device flow poll failed: HTTP ${pollResp.status}`);
1085
+ }
1086
+ const body = await pollResp.json();
1087
+ if (isAccessTokenSuccess(body)) {
1088
+ return body.access_token;
1089
+ }
1090
+ switch (body.error) {
1091
+ case "authorization_pending":
1092
+ continue;
1093
+ case "slow_down":
1094
+ intervalSec += 5;
1095
+ continue;
1096
+ case "expired_token":
1097
+ throw new Error(
1098
+ "device flow expired before authorization. Run `universe login` again."
1099
+ );
1100
+ case "access_denied":
1101
+ throw new Error("device flow access denied by user.");
1102
+ default:
1103
+ throw new Error(
1104
+ body.error_description ? `device flow error: ${body.error}: ${body.error_description}` : `device flow error: ${body.error}`
1105
+ );
1106
+ }
1107
+ }
772
1108
  }
773
1109
 
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);
1110
+ // src/commands/login.ts
1111
+ var DEFAULT_SCOPE = "read:org user:email";
1112
+ function emitJson2(envelope) {
1113
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1114
+ }
1115
+ async function login(options, deps = {}) {
1116
+ const env = deps.env ?? process.env;
1117
+ const runFlow = deps.runDeviceFlow ?? runDeviceFlow;
1118
+ const save = deps.saveToken ?? saveToken;
1119
+ const load = deps.loadToken ?? loadToken;
1120
+ const success = deps.logSuccess ?? ((s) => log2.success(s));
1121
+ const info = deps.logInfo ?? ((s) => log2.info(s));
1122
+ const error = deps.logError ?? ((s) => log2.error(s));
1123
+ const exit = deps.exit ?? exitWithCode;
1124
+ const envClientId = env["UNIVERSE_GH_CLIENT_ID"];
1125
+ const clientId = envClientId && envClientId.trim().length > 0 ? envClientId : DEFAULT_GH_CLIENT_ID;
1126
+ if (!options.force) {
1127
+ const existing = await load();
1128
+ if (existing) {
1129
+ const msg = "Already logged in. Run `universe logout` first or pass --force to replace the stored token.";
1130
+ if (options.json) {
1131
+ emitJson2({
1132
+ schemaVersion: "1",
1133
+ command: "login",
1134
+ success: false,
1135
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1136
+ error: { code: EXIT_CONFIRM, message: msg }
1137
+ });
1138
+ } else {
1139
+ error(msg);
1140
+ }
1141
+ exit(EXIT_CONFIRM);
1142
+ return;
784
1143
  }
785
1144
  }
786
- return [...deployIds].sort().reverse();
1145
+ let token;
1146
+ try {
1147
+ token = await runFlow({
1148
+ clientId,
1149
+ scope: DEFAULT_SCOPE,
1150
+ onPrompt: ({ userCode, verificationUri, expiresIn }) => {
1151
+ if (options.json) {
1152
+ emitJson2(
1153
+ buildEnvelope("login", true, {
1154
+ userCode,
1155
+ verificationUri,
1156
+ expiresIn,
1157
+ stored: false
1158
+ })
1159
+ );
1160
+ } else {
1161
+ info(
1162
+ [
1163
+ `Open ${verificationUri} in your browser`,
1164
+ `and enter code: ${userCode}`,
1165
+ `(code expires in ${Math.round(expiresIn / 60)} min)`
1166
+ ].join("\n")
1167
+ );
1168
+ }
1169
+ }
1170
+ });
1171
+ } catch (err) {
1172
+ const message = err instanceof Error ? err.message : String(err);
1173
+ if (options.json) {
1174
+ emitJson2({
1175
+ schemaVersion: "1",
1176
+ command: "login",
1177
+ success: false,
1178
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1179
+ error: { code: EXIT_CREDENTIALS, message }
1180
+ });
1181
+ } else {
1182
+ error(message);
1183
+ }
1184
+ exit(EXIT_CREDENTIALS);
1185
+ return;
1186
+ }
1187
+ await save(token);
1188
+ if (options.json) {
1189
+ emitJson2(buildEnvelope("login", true, { stored: true }));
1190
+ } else {
1191
+ success("Logged in. Token stored at ~/.config/universe-cli/token.");
1192
+ }
787
1193
  }
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;
1194
+
1195
+ // src/commands/logout.ts
1196
+ import { log as log3 } from "@clack/prompts";
1197
+ function emitJson3(envelope) {
1198
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1199
+ }
1200
+ async function logout(options, deps = {}) {
1201
+ const load = deps.loadToken ?? loadToken;
1202
+ const del = deps.deleteToken ?? deleteToken;
1203
+ const success = deps.logSuccess ?? ((s) => log3.success(s));
1204
+ const info = deps.logInfo ?? ((s) => log3.info(s));
1205
+ const existing = await load();
1206
+ await del();
1207
+ if (options.json) {
1208
+ emitJson3(buildEnvelope("logout", true, { removed: existing !== null }));
1209
+ return;
1210
+ }
1211
+ if (existing) {
1212
+ success("Logged out. Stored token removed.");
1213
+ } else {
1214
+ info("No token was stored. Nothing to remove.");
1215
+ }
792
1216
  }
793
1217
 
794
- // 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`
812
- );
813
- exitWithCode(
814
- EXIT_DEPLOY_NOT_FOUND,
815
- `Deploy ${options.deployId} not found`
1218
+ // src/commands/ls.ts
1219
+ import { readFile as readFile3 } from "fs/promises";
1220
+ import { resolve as resolve3 } from "path";
1221
+ import { log as log4 } from "@clack/prompts";
1222
+ var defaultReadPlatformYaml2 = async (cwd) => {
1223
+ return readFile3(resolve3(cwd, "platform.yaml"), "utf-8");
1224
+ };
1225
+ function emitJson4(envelope) {
1226
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1227
+ }
1228
+ var DEPLOY_ID_RE = /^(\d{8})-(\d{6})-([a-f0-9]+)$/i;
1229
+ function parseDeployId(deployId) {
1230
+ const m = DEPLOY_ID_RE.exec(deployId);
1231
+ if (!m) return { deployId, timestamp: null, sha: null };
1232
+ const [, ymd, hms, sha] = m;
1233
+ if (!ymd || !hms || !sha) return { deployId, timestamp: null, sha: null };
1234
+ 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`;
1235
+ return { deployId, timestamp: iso, sha };
1236
+ }
1237
+ async function readSiteFromYaml(cwd, read) {
1238
+ let raw;
1239
+ try {
1240
+ raw = await read(cwd);
1241
+ } catch (err) {
1242
+ if (err instanceof Error && err.code === "ENOENT") {
1243
+ return null;
1244
+ }
1245
+ throw err;
1246
+ }
1247
+ const r = parsePlatformYaml(raw);
1248
+ if (!r.ok) throw new ConfigError(r.error);
1249
+ const config = r.value;
1250
+ return config.site;
1251
+ }
1252
+ function formatTable(deploys) {
1253
+ const header = ["DEPLOY ID", "TIMESTAMP", "SHA"];
1254
+ const rows = deploys.map((d) => [
1255
+ d.deployId,
1256
+ d.timestamp ? d.timestamp.replace("T", " ").replace("Z", "") : "\u2014",
1257
+ d.sha ?? "\u2014"
1258
+ ]);
1259
+ const widths = header.map(
1260
+ (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
1261
+ );
1262
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
1263
+ return [fmt(header), ...rows.map(fmt)].join("\n");
1264
+ }
1265
+ async function ls(options, deps = {}) {
1266
+ const cwd = deps.cwd ?? process.cwd();
1267
+ const env = deps.env ?? process.env;
1268
+ const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml2;
1269
+ const resolveId = deps.resolveIdentity ?? resolveIdentity;
1270
+ const mkClient = deps.createProxyClient ?? createProxyClient;
1271
+ const success = deps.logSuccess ?? ((s) => log4.success(s));
1272
+ const info = deps.logInfo ?? ((s) => log4.info(s));
1273
+ const error = deps.logError ?? ((s) => log4.error(s));
1274
+ const exit = deps.exit ?? exitWithCode;
1275
+ try {
1276
+ const identity = await resolveId({ env });
1277
+ if (!identity) {
1278
+ throw new CredentialError(
1279
+ "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
816
1280
  );
817
- return;
818
1281
  }
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"
1282
+ let site = options.site?.trim() || null;
1283
+ if (!site) {
1284
+ site = await readSiteFromYaml(cwd, readYaml);
1285
+ }
1286
+ if (!site) {
1287
+ throw new ConfigError(
1288
+ "No site to list. Run from a directory with `platform.yaml`, or pass `--site <name>`."
827
1289
  );
828
- exitWithCode(
829
- EXIT_ALIAS,
830
- "No preview alias set \u2014 deploy first or specify a deploy ID"
1290
+ }
1291
+ const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
1292
+ const client = mkClient({
1293
+ baseUrl,
1294
+ getAuthToken: () => identity.token
1295
+ });
1296
+ const raw = await client.siteDeploys({ site });
1297
+ const deploys = raw.map((d) => parseDeployId(d.deployId));
1298
+ if (options.json) {
1299
+ emitJson4(
1300
+ buildEnvelope("ls", true, {
1301
+ site,
1302
+ deploys,
1303
+ identitySource: identity.source
1304
+ })
831
1305
  );
832
1306
  return;
833
1307
  }
834
- deployId = preview;
1308
+ if (deploys.length === 0) {
1309
+ info(`(no deploys for ${site})`);
1310
+ return;
1311
+ }
1312
+ success(formatTable(deploys));
1313
+ } catch (err) {
1314
+ const { code, message } = wrapProxyError("ls", err);
1315
+ if (options.json) {
1316
+ emitJson4(buildErrorEnvelope("ls", code, message));
1317
+ } else {
1318
+ error(message);
1319
+ }
1320
+ exit(code);
1321
+ }
1322
+ }
1323
+
1324
+ // src/commands/promote.ts
1325
+ import { readFile as readFile4 } from "fs/promises";
1326
+ import { resolve as resolve4 } from "path";
1327
+ import { log as log5 } from "@clack/prompts";
1328
+ var defaultReadPlatformYaml3 = async (cwd) => {
1329
+ return readFile4(resolve4(cwd, "platform.yaml"), "utf-8");
1330
+ };
1331
+ function emitJson5(envelope) {
1332
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1333
+ }
1334
+ async function readAndParseConfig2(cwd, read) {
1335
+ let raw;
1336
+ try {
1337
+ raw = await read(cwd);
1338
+ } catch (err) {
1339
+ if (err instanceof Error && err.code === "ENOENT") {
1340
+ throw new ConfigError(
1341
+ `platform.yaml not found in ${cwd}. See docs/platform-yaml.md.`
1342
+ );
1343
+ }
1344
+ throw err;
1345
+ }
1346
+ const r = parsePlatformYaml(raw);
1347
+ if (!r.ok) throw new ConfigError(r.error);
1348
+ return r.value;
1349
+ }
1350
+ async function promote(options, deps = {}) {
1351
+ const cwd = deps.cwd ?? process.cwd();
1352
+ const env = deps.env ?? process.env;
1353
+ const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml3;
1354
+ const resolveId = deps.resolveIdentity ?? resolveIdentity;
1355
+ const mkClient = deps.createProxyClient ?? createProxyClient;
1356
+ const success = deps.logSuccess ?? ((s) => log5.success(s));
1357
+ const error = deps.logError ?? ((s) => log5.error(s));
1358
+ const exit = deps.exit ?? exitWithCode;
1359
+ try {
1360
+ const identity = await resolveId({ env });
1361
+ if (!identity) {
1362
+ throw new CredentialError(
1363
+ "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
1364
+ );
1365
+ }
1366
+ const config = await readAndParseConfig2(cwd, readYaml);
1367
+ const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
1368
+ const client = mkClient({
1369
+ baseUrl,
1370
+ getAuthToken: () => identity.token
1371
+ });
1372
+ let result;
1373
+ if (options.from) {
1374
+ result = await client.siteRollback({
1375
+ site: config.site,
1376
+ to: options.from
1377
+ });
1378
+ } else {
1379
+ result = await client.sitePromote({ site: config.site });
1380
+ }
1381
+ if (options.json) {
1382
+ emitJson5(
1383
+ buildEnvelope("promote", true, {
1384
+ deployId: result.deployId,
1385
+ url: result.url,
1386
+ site: config.site,
1387
+ identitySource: identity.source
1388
+ })
1389
+ );
1390
+ } else {
1391
+ success(
1392
+ [
1393
+ `Promoted ${result.deployId} to production`,
1394
+ ``,
1395
+ ` Site: ${config.site}`,
1396
+ ` Deploy: ${result.deployId}`,
1397
+ ` Production: ${result.url}`
1398
+ ].join("\n")
1399
+ );
1400
+ }
1401
+ } catch (err) {
1402
+ const { code, message } = wrapProxyError("promote", err);
1403
+ if (options.json) {
1404
+ emitJson5(buildErrorEnvelope("promote", code, message));
1405
+ } else {
1406
+ error(message);
1407
+ }
1408
+ exit(code);
835
1409
  }
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
- });
851
1410
  }
852
1411
 
853
1412
  // 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;
1413
+ import { readFile as readFile5 } from "fs/promises";
1414
+ import { resolve as resolve5 } from "path";
1415
+ import { log as log6 } from "@clack/prompts";
1416
+ var defaultReadPlatformYaml4 = async (cwd) => {
1417
+ return readFile5(resolve5(cwd, "platform.yaml"), "utf-8");
1418
+ };
1419
+ function emitJson6(envelope) {
1420
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1421
+ }
1422
+ async function readAndParseConfig3(cwd, read) {
1423
+ let raw;
1424
+ try {
1425
+ raw = await read(cwd);
1426
+ } catch (err) {
1427
+ if (err instanceof Error && err.code === "ENOENT") {
1428
+ throw new ConfigError(
1429
+ `platform.yaml not found in ${cwd}. See docs/platform-yaml.md.`
1430
+ );
1431
+ }
1432
+ throw err;
873
1433
  }
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");
1434
+ const r = parsePlatformYaml(raw);
1435
+ if (!r.ok) throw new ConfigError(r.error);
1436
+ return r.value;
1437
+ }
1438
+ async function rollback(options, deps = {}) {
1439
+ const cwd = deps.cwd ?? process.cwd();
1440
+ const env = deps.env ?? process.env;
1441
+ const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml4;
1442
+ const resolveId = deps.resolveIdentity ?? resolveIdentity;
1443
+ const mkClient = deps.createProxyClient ?? createProxyClient;
1444
+ const success = deps.logSuccess ?? ((s) => log6.success(s));
1445
+ const error = deps.logError ?? ((s) => log6.error(s));
1446
+ const exit = deps.exit ?? exitWithCode;
1447
+ try {
1448
+ if (!options.to || options.to.trim().length === 0) {
1449
+ throw new UsageError(
1450
+ "rollback requires --to <deployId>. Run `universe static ls` to list past deploys."
1451
+ );
1452
+ }
1453
+ const identity = await resolveId({ env });
1454
+ if (!identity) {
1455
+ throw new CredentialError(
1456
+ "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
1457
+ );
1458
+ }
1459
+ const config = await readAndParseConfig3(cwd, readYaml);
1460
+ const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
1461
+ const client = mkClient({
1462
+ baseUrl,
1463
+ getAuthToken: () => identity.token
1464
+ });
1465
+ const result = await client.siteRollback({
1466
+ site: config.site,
1467
+ to: options.to.trim()
1468
+ });
1469
+ if (options.json) {
1470
+ emitJson6(
1471
+ buildEnvelope("rollback", true, {
1472
+ deployId: result.deployId,
1473
+ url: result.url,
1474
+ site: config.site,
1475
+ identitySource: identity.source
1476
+ })
1477
+ );
1478
+ } else {
1479
+ success(
1480
+ [
1481
+ `Rolled production back to ${result.deployId}`,
1482
+ ``,
1483
+ ` Site: ${config.site}`,
1484
+ ` Deploy: ${result.deployId}`,
1485
+ ` Production: ${result.url}`
1486
+ ].join("\n")
1487
+ );
1488
+ }
1489
+ } catch (err) {
1490
+ const { code, message } = wrapProxyError("rollback", err);
1491
+ if (options.json) {
1492
+ emitJson6(buildErrorEnvelope("rollback", code, message));
1493
+ } else {
1494
+ error(message);
1495
+ }
1496
+ exit(code);
1497
+ }
1498
+ }
1499
+
1500
+ // src/commands/whoami.ts
1501
+ import { log as log7 } from "@clack/prompts";
1502
+ var DEFAULT_PROXY_URL2 = "https://uploads.freecode.camp";
1503
+ function emitJson7(envelope) {
1504
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1505
+ }
1506
+ async function whoami(options, deps = {}) {
1507
+ const env = deps.env ?? process.env;
1508
+ const resolve6 = deps.resolveIdentity ?? resolveIdentity;
1509
+ const mkClient = deps.createProxyClient ?? createProxyClient;
1510
+ const success = deps.logSuccess ?? ((s) => log7.success(s));
1511
+ const error = deps.logError ?? ((s) => log7.error(s));
1512
+ const exit = deps.exit ?? exitWithCode;
1513
+ const identity = await resolve6({ env });
1514
+ if (!identity) {
1515
+ const msg = "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI.";
1516
+ if (options.json) {
1517
+ emitJson7(buildErrorEnvelope("whoami", EXIT_CREDENTIALS, msg));
1518
+ } else {
1519
+ error(msg);
1520
+ }
1521
+ exit(EXIT_CREDENTIALS);
880
1522
  return;
881
1523
  }
882
- if (options.json && !options.confirm) {
883
- outputError(
884
- ctx,
885
- EXIT_CONFIRM,
886
- "Rollback requires --confirm flag in JSON mode"
1524
+ const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL2;
1525
+ const client = mkClient({
1526
+ baseUrl,
1527
+ getAuthToken: () => identity.token
1528
+ });
1529
+ try {
1530
+ const result = await client.whoami();
1531
+ const count = result.authorizedSites.length;
1532
+ if (options.json) {
1533
+ emitJson7(
1534
+ buildEnvelope("whoami", true, {
1535
+ login: result.login,
1536
+ identitySource: identity.source,
1537
+ authorizedSitesCount: count
1538
+ })
1539
+ );
1540
+ } else {
1541
+ const sitesLine = count === 0 ? "Authorized for 0 sites." : `Authorized for ${count} site${count === 1 ? "" : "s"} \u2014 run \`universe sites ls --mine\``;
1542
+ success(
1543
+ [
1544
+ `Logged in as: ${result.login}`,
1545
+ `Identity source: ${identity.source}`,
1546
+ sitesLine
1547
+ ].join("\n")
1548
+ );
1549
+ }
1550
+ } catch (err) {
1551
+ const exitCode = err instanceof CliError ? err.exitCode : EXIT_CREDENTIALS;
1552
+ const message = err instanceof ProxyError ? `whoami failed (${err.code}): ${err.message}` : err instanceof Error ? err.message : String(err);
1553
+ if (options.json) {
1554
+ emitJson7(buildErrorEnvelope("whoami", exitCode, message));
1555
+ } else {
1556
+ error(message);
1557
+ }
1558
+ exit(exitCode);
1559
+ }
1560
+ }
1561
+
1562
+ // src/commands/sites/ls.ts
1563
+ import { log as log8 } from "@clack/prompts";
1564
+
1565
+ // src/commands/sites/_shared.ts
1566
+ function emitJson8(envelope) {
1567
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1568
+ }
1569
+ function parseTeamsFlag(raw) {
1570
+ if (raw === void 0 || raw === null) return [];
1571
+ const tokens = Array.isArray(raw) ? raw : [raw];
1572
+ return tokens.flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
1573
+ }
1574
+ async function setupClient(deps) {
1575
+ const env = deps.env ?? process.env;
1576
+ const resolveId = deps.resolveIdentity ?? resolveIdentity;
1577
+ const mkClient = deps.createProxyClient ?? createProxyClient;
1578
+ const identity = await resolveId({ env });
1579
+ if (!identity) {
1580
+ throw new CredentialError(
1581
+ "No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI."
887
1582
  );
888
- exitWithCode(EXIT_CONFIRM, "Rollback requires --confirm flag in JSON mode");
889
- return;
890
1583
  }
891
- if (!options.json && !options.confirm) {
892
- const confirmed = await confirm({
893
- message: `Rollback production from ${currentDeployId} to ${previousDeploy}?`
1584
+ const baseUrl = env["UNIVERSE_PROXY_URL"] ?? DEFAULT_PROXY_URL;
1585
+ const client = mkClient({
1586
+ baseUrl,
1587
+ getAuthToken: () => identity.token
1588
+ });
1589
+ return { client, identitySource: identity.source };
1590
+ }
1591
+
1592
+ // src/commands/sites/ls.ts
1593
+ function formatTable2(rows) {
1594
+ if (rows.length === 0) return "No registered sites.";
1595
+ const headers = ["SLUG", "TEAMS", "CREATED BY", "CREATED AT"];
1596
+ const cells = rows.map((r) => [
1597
+ r.slug,
1598
+ r.teams.join(","),
1599
+ r.createdBy,
1600
+ r.createdAt
1601
+ ]);
1602
+ const widths = headers.map(
1603
+ (h, i) => Math.max(h.length, ...cells.map((row) => row[i]?.length ?? 0))
1604
+ );
1605
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
1606
+ return [fmt(headers), ...cells.map(fmt)].join("\n");
1607
+ }
1608
+ async function ls2(options, deps = {}) {
1609
+ const command = "sites ls";
1610
+ const success = deps.logSuccess ?? ((s) => log8.message(s));
1611
+ const error = deps.logError ?? ((s) => log8.error(s));
1612
+ const exit = deps.exit ?? exitWithCode;
1613
+ try {
1614
+ const { client } = await setupClient(deps);
1615
+ let rows = await client.listSites();
1616
+ let scope = "all";
1617
+ if (options.mine) {
1618
+ const me = await client.whoami();
1619
+ const allowed = new Set(me.authorizedSites);
1620
+ rows = rows.filter((r) => allowed.has(r.slug));
1621
+ scope = "mine";
1622
+ }
1623
+ if (options.json) {
1624
+ emitJson8(
1625
+ buildEnvelope(command, true, {
1626
+ count: rows.length,
1627
+ scope,
1628
+ sites: rows
1629
+ })
1630
+ );
1631
+ } else {
1632
+ success(formatTable2(rows));
1633
+ }
1634
+ } catch (err) {
1635
+ const { code, message } = wrapProxyError(command, err);
1636
+ if (options.json) {
1637
+ emitJson8(buildErrorEnvelope(command, code, message));
1638
+ } else {
1639
+ error(message);
1640
+ }
1641
+ exit(code);
1642
+ }
1643
+ }
1644
+
1645
+ // src/commands/sites/register.ts
1646
+ import { log as log9 } from "@clack/prompts";
1647
+ async function register(options, deps = {}) {
1648
+ const command = "sites register";
1649
+ const success = deps.logSuccess ?? ((s) => log9.success(s));
1650
+ const error = deps.logError ?? ((s) => log9.error(s));
1651
+ const exit = deps.exit ?? exitWithCode;
1652
+ try {
1653
+ if (!options.slug || options.slug.trim().length === 0) {
1654
+ throw new UsageError("slug is required (positional argument)");
1655
+ }
1656
+ const teams = parseTeamsFlag(options.team);
1657
+ const { client } = await setupClient(deps);
1658
+ const row = await client.registerSite({
1659
+ slug: options.slug,
1660
+ teams: teams.length > 0 ? teams : void 0
894
1661
  });
895
- if (!confirmed || typeof confirmed === "symbol") {
896
- return;
1662
+ if (options.json) {
1663
+ emitJson8(
1664
+ buildEnvelope(command, true, {
1665
+ slug: row.slug,
1666
+ teams: row.teams,
1667
+ createdAt: row.createdAt,
1668
+ createdBy: row.createdBy
1669
+ })
1670
+ );
1671
+ } else {
1672
+ success(
1673
+ [
1674
+ `Registered ${row.slug}`,
1675
+ ``,
1676
+ ` Slug: ${row.slug}`,
1677
+ ` Teams: ${row.teams.join(", ")}`,
1678
+ ` Created by: ${row.createdBy}`,
1679
+ ` Created at: ${row.createdAt}`
1680
+ ].join("\n")
1681
+ );
897
1682
  }
1683
+ } catch (err) {
1684
+ const { code, message } = wrapProxyError(command, err);
1685
+ if (options.json) {
1686
+ emitJson8(buildErrorEnvelope(command, code, message));
1687
+ } else {
1688
+ error(message);
1689
+ }
1690
+ exit(code);
898
1691
  }
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
1692
+ }
1693
+
1694
+ // src/commands/sites/rm.ts
1695
+ import { log as log10 } from "@clack/prompts";
1696
+ async function rm2(options, deps = {}) {
1697
+ const command = "sites rm";
1698
+ const success = deps.logSuccess ?? ((s) => log10.success(s));
1699
+ const error = deps.logError ?? ((s) => log10.error(s));
1700
+ const exit = deps.exit ?? exitWithCode;
1701
+ try {
1702
+ if (!options.slug || options.slug.trim().length === 0) {
1703
+ throw new UsageError("slug is required (positional argument)");
1704
+ }
1705
+ const { client } = await setupClient(deps);
1706
+ await client.deleteSite({ slug: options.slug });
1707
+ if (options.json) {
1708
+ emitJson8(
1709
+ buildEnvelope(command, true, {
1710
+ slug: options.slug,
1711
+ deleted: true
1712
+ })
1713
+ );
1714
+ } else {
1715
+ success(
1716
+ [
1717
+ `Deleted ${options.slug}`,
1718
+ ``,
1719
+ ` Note: R2 deploy bytes are NOT removed; they age out via the`,
1720
+ ` post-GA cleanup cron.`
1721
+ ].join("\n")
1722
+ );
1723
+ }
1724
+ } catch (err) {
1725
+ const { code, message } = wrapProxyError(command, err);
1726
+ if (options.json) {
1727
+ emitJson8(buildErrorEnvelope(command, code, message));
1728
+ } else {
1729
+ error(message);
1730
+ }
1731
+ exit(code);
1732
+ }
1733
+ }
1734
+
1735
+ // src/commands/sites/update.ts
1736
+ import { log as log11 } from "@clack/prompts";
1737
+ async function update(options, deps = {}) {
1738
+ const command = "sites update";
1739
+ const success = deps.logSuccess ?? ((s) => log11.success(s));
1740
+ const error = deps.logError ?? ((s) => log11.error(s));
1741
+ const exit = deps.exit ?? exitWithCode;
1742
+ try {
1743
+ if (!options.slug || options.slug.trim().length === 0) {
1744
+ throw new UsageError("slug is required (positional argument)");
1745
+ }
1746
+ const teams = parseTeamsFlag(options.team);
1747
+ if (teams.length === 0) {
1748
+ throw new UsageError(
1749
+ "--team is required with at least one slug; use `sites rm` to remove a site"
1750
+ );
1751
+ }
1752
+ const { client } = await setupClient(deps);
1753
+ const row = await client.updateSite({
1754
+ slug: options.slug,
1755
+ teams
1756
+ });
1757
+ if (options.json) {
1758
+ emitJson8(
1759
+ buildEnvelope(command, true, {
1760
+ slug: row.slug,
1761
+ teams: row.teams,
1762
+ updatedAt: row.updatedAt
1763
+ })
1764
+ );
1765
+ } else {
1766
+ success(
1767
+ [
1768
+ `Updated ${row.slug}`,
1769
+ ``,
1770
+ ` Slug: ${row.slug}`,
1771
+ ` Teams: ${row.teams.join(", ")}`,
1772
+ ` Updated at: ${row.updatedAt}`
1773
+ ].join("\n")
1774
+ );
1775
+ }
1776
+ } catch (err) {
1777
+ const { code, message } = wrapProxyError(command, err);
1778
+ if (options.json) {
1779
+ emitJson8(buildErrorEnvelope(command, code, message));
1780
+ } else {
1781
+ error(message);
1782
+ }
1783
+ exit(code);
1784
+ }
1785
+ }
1786
+
1787
+ // src/output/format.ts
1788
+ import { log as log12 } from "@clack/prompts";
1789
+
1790
+ // src/output/redact.ts
1791
+ var AWS_KEY_PREFIX_PATTERN = /(?:AKIA|ASIA|AROA|AIDA|ACCA|ANPA|ABIA|AGPA)[A-Z0-9]{12,}/g;
1792
+ var URL_CREDS_PATTERN = /https?:\/\/[^@\s]+@/g;
1793
+ 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;
1794
+ var HEX_CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key)\s*[=:]\s*([a-f0-9]{32,})/gi;
1795
+ var JSON_CREDENTIAL_PATTERN = /"(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key|accessKeyId|secretAccessKey)"\s*:\s*"[^"]+"/gi;
1796
+ var BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
1797
+ function maskAwsKey(match) {
1798
+ return match.slice(0, 4) + "****" + match.slice(-4);
1799
+ }
1800
+ function maskUrlCreds(match) {
1801
+ const atIndex = match.lastIndexOf("@");
1802
+ const protocolEnd = match.indexOf("://") + 3;
1803
+ return match.slice(0, protocolEnd) + "****:****@" + match.slice(atIndex + 1);
1804
+ }
1805
+ function redact(value) {
1806
+ let result = value;
1807
+ result = result.replace(URL_CREDS_PATTERN, maskUrlCreds);
1808
+ result = result.replace(AWS_KEY_PREFIX_PATTERN, maskAwsKey);
1809
+ result = result.replace(BEARER_PATTERN, "Bearer ****");
1810
+ result = result.replace(JSON_CREDENTIAL_PATTERN, (match) => {
1811
+ const colonIndex = match.indexOf(":");
1812
+ return match.slice(0, colonIndex + 1) + '"****"';
915
1813
  });
1814
+ result = result.replace(
1815
+ CREDENTIAL_CONTEXT_PATTERN,
1816
+ (_match, _secret, _offset, _full) => {
1817
+ const eqIndex = _match.indexOf("=");
1818
+ const colonIndex = _match.indexOf(":");
1819
+ const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
1820
+ const prefix = _match.slice(0, sepIndex + 1);
1821
+ return prefix + "****";
1822
+ }
1823
+ );
1824
+ result = result.replace(HEX_CREDENTIAL_CONTEXT_PATTERN, (_match, _secret) => {
1825
+ const eqIndex = _match.indexOf("=");
1826
+ const colonIndex = _match.indexOf(":");
1827
+ const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
1828
+ const prefix = _match.slice(0, sepIndex + 1);
1829
+ return prefix + "****";
1830
+ });
1831
+ return result;
1832
+ }
1833
+
1834
+ // src/output/format.ts
1835
+ function outputError(ctx, code, message, issues) {
1836
+ const redactedMessage = redact(message);
1837
+ const redactedIssues = issues?.map(redact);
1838
+ if (ctx.json) {
1839
+ const envelope = buildErrorEnvelope(
1840
+ ctx.command,
1841
+ code,
1842
+ redactedMessage,
1843
+ redactedIssues
1844
+ );
1845
+ process.stdout.write(JSON.stringify(envelope) + "\n");
1846
+ } else {
1847
+ log12.error(redactedMessage);
1848
+ }
916
1849
  }
917
1850
 
918
1851
  // src/cli.ts
919
- var version = true ? "0.3.3" : "0.0.0";
1852
+ var version = true ? "0.5.0" : "0.0.0";
920
1853
  function handleActionError(command, json, err) {
921
1854
  const ctx = { json, command };
922
1855
  const message = err instanceof Error ? err.message : "unknown error";
923
1856
  const code = err instanceof CliError ? err.exitCode : EXIT_USAGE;
924
1857
  outputError(ctx, code, message);
925
- exitWithCode(code, message);
1858
+ exitWithCode(code);
1859
+ }
1860
+ function findFirstPositional(args) {
1861
+ for (let i = 0; i < args.length; i += 1) {
1862
+ const a = args[i];
1863
+ if (typeof a === "string" && !a.startsWith("-")) return i;
1864
+ }
1865
+ return -1;
926
1866
  }
927
1867
  function run(argv = process.argv) {
928
1868
  const args = argv.slice(2);
929
- if (args[0] === "static") {
930
- 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(
932
- async (flags) => {
1869
+ const firstPosIdx = findFirstPositional(args);
1870
+ const namespace = firstPosIdx >= 0 ? args[firstPosIdx] : void 0;
1871
+ const isStatic = namespace === "static";
1872
+ const isSites = namespace === "sites";
1873
+ if (isSites) {
1874
+ const sitesArgs = [
1875
+ ...args.slice(0, firstPosIdx),
1876
+ ...args.slice(firstPosIdx + 1)
1877
+ ];
1878
+ const sitesCli = cac("universe sites");
1879
+ sitesCli.command("register <slug>", "Register a new static site (staff only)").option("--json", "Output as JSON").option(
1880
+ "--team <name>",
1881
+ "GitHub team slug (repeatable, or comma-separated). Defaults to staff."
1882
+ ).action(
1883
+ async (slug, flags) => {
933
1884
  try {
934
- await deploy({
1885
+ await register({
935
1886
  json: flags.json ?? false,
936
- force: flags.force ?? false,
937
- outputDir: flags.outputDir
1887
+ slug,
1888
+ team: flags.team
938
1889
  });
939
1890
  } catch (err) {
940
- handleActionError("deploy", flags.json ?? false, err);
1891
+ handleActionError("sites register", flags.json ?? false, err);
941
1892
  }
942
1893
  }
943
1894
  );
944
- staticCli.command("promote [deploy-id]", "Promote a deploy to production").option("--json", "Output as JSON").action(
945
- async (deployId, flags) => {
1895
+ sitesCli.command("ls", "List sites in the registry").option("--json", "Output as JSON").option(
1896
+ "--mine",
1897
+ "Filter to sites your GitHub identity is authorized for"
1898
+ ).action(async (flags) => {
1899
+ try {
1900
+ await ls2({
1901
+ json: flags.json ?? false,
1902
+ mine: flags.mine ?? false
1903
+ });
1904
+ } catch (err) {
1905
+ handleActionError("sites ls", flags.json ?? false, err);
1906
+ }
1907
+ });
1908
+ sitesCli.command(
1909
+ "update <slug>",
1910
+ "Replace the teams list for an existing site (staff only)"
1911
+ ).option("--json", "Output as JSON").option(
1912
+ "--team <name>",
1913
+ "GitHub team slug (repeatable, or comma-separated). Required."
1914
+ ).action(
1915
+ async (slug, flags) => {
946
1916
  try {
947
- await promote({ json: flags.json ?? false, deployId });
1917
+ await update({
1918
+ json: flags.json ?? false,
1919
+ slug,
1920
+ team: flags.team
1921
+ });
948
1922
  } catch (err) {
949
- handleActionError("promote", flags.json ?? false, err);
1923
+ handleActionError("sites update", flags.json ?? false, err);
950
1924
  }
951
1925
  }
952
1926
  );
953
- staticCli.command("rollback", "Rollback production to previous deploy").option("--json", "Output as JSON").option("--confirm", "Confirm rollback").action(async (flags) => {
1927
+ sitesCli.command("rm <slug>", "Remove a site from the registry (staff only)").option("--json", "Output as JSON").action(async (slug, flags) => {
1928
+ try {
1929
+ await rm2({ json: flags.json ?? false, slug });
1930
+ } catch (err) {
1931
+ handleActionError("sites rm", flags.json ?? false, err);
1932
+ }
1933
+ });
1934
+ sitesCli.help();
1935
+ sitesCli.version(version);
1936
+ sitesCli.parse(["node", "universe-sites", ...sitesArgs]);
1937
+ return;
1938
+ }
1939
+ if (isStatic) {
1940
+ const staticArgs = [
1941
+ ...args.slice(0, firstPosIdx),
1942
+ ...args.slice(firstPosIdx + 1)
1943
+ ];
1944
+ const staticCli = cac("universe static");
1945
+ 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(
1946
+ async (flags) => {
1947
+ try {
1948
+ await deploy({
1949
+ json: flags.json ?? false,
1950
+ promote: flags.promote ?? false,
1951
+ dir: flags.dir
1952
+ });
1953
+ } catch (err) {
1954
+ handleActionError("deploy", flags.json ?? false, err);
1955
+ }
1956
+ }
1957
+ );
1958
+ staticCli.command("promote", "Promote the current preview to production").option("--json", "Output as JSON").option(
1959
+ "--from <deployId>",
1960
+ "Promote a specific past deploy id (alias rewrite)"
1961
+ ).action(async (flags) => {
1962
+ try {
1963
+ await promote({
1964
+ json: flags.json ?? false,
1965
+ from: flags.from
1966
+ });
1967
+ } catch (err) {
1968
+ handleActionError("promote", flags.json ?? false, err);
1969
+ }
1970
+ });
1971
+ 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
1972
  try {
955
1973
  await rollback({
956
1974
  json: flags.json ?? false,
957
- confirm: flags.confirm ?? false
1975
+ to: flags.to
958
1976
  });
959
1977
  } catch (err) {
960
1978
  handleActionError("rollback", flags.json ?? false, err);
961
1979
  }
962
1980
  });
1981
+ 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) => {
1982
+ try {
1983
+ await ls({
1984
+ json: flags.json ?? false,
1985
+ site: flags.site
1986
+ });
1987
+ } catch (err) {
1988
+ handleActionError("ls", flags.json ?? false, err);
1989
+ }
1990
+ });
963
1991
  staticCli.help();
964
1992
  staticCli.version(version);
965
- staticCli.parse(["node", "universe-static", ...args.slice(1)]);
1993
+ staticCli.parse(["node", "universe-static", ...staticArgs]);
966
1994
  } else {
967
1995
  const cli = cac("universe");
968
- cli.command("static [...args]", "Static site deployment commands").action(() => {
969
- console.log("Run: universe static --help");
1996
+ 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) => {
1997
+ try {
1998
+ await login({
1999
+ json: flags.json ?? false,
2000
+ force: flags.force ?? false
2001
+ });
2002
+ } catch (err) {
2003
+ handleActionError("login", flags.json ?? false, err);
2004
+ }
2005
+ });
2006
+ cli.command("logout", "Remove the stored GitHub device-flow token").option("--json", "Output as JSON").action(async (flags) => {
2007
+ try {
2008
+ await logout({ json: flags.json ?? false });
2009
+ } catch (err) {
2010
+ handleActionError("logout", flags.json ?? false, err);
2011
+ }
2012
+ });
2013
+ cli.command("whoami", "Show resolved GitHub identity and authorized sites").option("--json", "Output as JSON").action(async (flags) => {
2014
+ try {
2015
+ await whoami({ json: flags.json ?? false });
2016
+ } catch (err) {
2017
+ handleActionError("whoami", flags.json ?? false, err);
2018
+ }
970
2019
  });
2020
+ cli.command("static <subcommand>", "Static site deployment commands");
2021
+ cli.command("sites <subcommand>", "Static site registry commands");
971
2022
  cli.help();
972
2023
  cli.version(version);
973
2024
  cli.parse(argv);