juicyjuicy 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5156,7 +5156,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
5156
5156
  var NONE = 0;
5157
5157
  var ASSUMED = 1;
5158
5158
  var PROVEN = 2;
5159
- function isRecursive(inst, stack, resolve5) {
5159
+ function isRecursive(inst, stack, resolve6) {
5160
5160
  const cached2 = recursive.get(inst);
5161
5161
  if (cached2 !== void 0)
5162
5162
  return cached2 ? PROVEN : NONE;
@@ -5166,7 +5166,7 @@ function isRecursive(inst, stack, resolve5) {
5166
5166
  let result = NONE;
5167
5167
  const check3 = (child) => {
5168
5168
  if (result !== PROVEN && child?._zod) {
5169
- const answer = isRecursive(child, stack, resolve5);
5169
+ const answer = isRecursive(child, stack, resolve6);
5170
5170
  if (answer > result)
5171
5171
  result = answer;
5172
5172
  }
@@ -5177,7 +5177,7 @@ function isRecursive(inst, stack, resolve5) {
5177
5177
  const desc = Object.getOwnPropertyDescriptor(sh, key);
5178
5178
  if (spread && !desc.enumerable)
5179
5179
  continue;
5180
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve5) : NONE;
5180
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve6) : NONE;
5181
5181
  if (child > answer)
5182
5182
  answer = child;
5183
5183
  }
@@ -5241,7 +5241,7 @@ function isRecursive(inst, stack, resolve5) {
5241
5241
  break;
5242
5242
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
5243
5243
  case "lazy": {
5244
- const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
5244
+ const inner = def._cachedInner ?? (resolve6 ? inst._zod.innerType : void 0);
5245
5245
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
5246
5246
  break;
5247
5247
  }
@@ -19818,76 +19818,33 @@ var LIMITS = {
19818
19818
  maxThumbnailBytes: 2 * MB,
19819
19819
  maxHowItWasMadeBytes: 20 * 1024,
19820
19820
  maxApiTokensPerUser: 20,
19821
- maxTokenNameLength: 60
19821
+ maxTokenNameLength: 60,
19822
+ /** Characters in a comment (plain text, ARCHITECTURE §14). */
19823
+ maxCommentLength: 1e3,
19824
+ /** Characters in a report's details. */
19825
+ maxReportDetailsLength: 1e3,
19826
+ /** Bytes a creator's kept versions may take up in total, across all their games (S4). */
19827
+ maxStorageBytesPerUser: 2 * 1024 * MB
19822
19828
  };
19823
- var THUMBNAIL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
19824
-
19825
- // ../shared/src/api.ts
19826
- var PUBLISH_SOURCES = ["cli", "web", "github"];
19827
- var startVersionRequestSchema = external_exports.object({
19828
- /** Raw `juicy.json`. Its `slug` must match the URL. */
19829
- manifest: external_exports.unknown(),
19830
- /** The build folder after `planUpload`. */
19831
- files: external_exports.array(external_exports.object({ path: external_exports.string().max(1024), size: external_exports.number() })).max(LIMITS.maxFiles * 4),
19832
- thumbnail: external_exports.object({ sha256: external_exports.string(), size: external_exports.number(), extension: external_exports.string() }).optional(),
19833
- /** Contents of the manifest's `howItWasMade` markdown file. */
19834
- howItWasMade: external_exports.string().optional(),
19835
- /** A note for the version history, e.g. what changed or a commit SHA. */
19836
- message: external_exports.string().trim().max(200).optional(),
19837
- source: external_exports.enum(PUBLISH_SOURCES).default("cli")
19838
- });
19839
-
19840
- // ../shared/src/device.ts
19841
- var DEVICE_LOGIN = {
19842
- /** How long a code can be approved. */
19843
- expiresInSeconds: 10 * 60,
19844
- /** Minimum seconds between polls; polling faster answers SLOW_DOWN. */
19845
- intervalSeconds: 5,
19846
- /** RFC 8628: after SLOW_DOWN, the client adds this many seconds to its interval. */
19847
- slowDownSeconds: 5,
19848
- maxClientNameLength: 60
19849
- };
19850
- var deviceCodeRequestSchema = external_exports.object({
19851
- /** Shown on the approval page and used as the token's name, e.g. "juicyjuicy CLI on Lukasz-PC". */
19852
- clientName: external_exports.string().trim().min(1).max(DEVICE_LOGIN.maxClientNameLength).optional()
19853
- });
19854
- var deviceTokenRequestSchema = external_exports.object({ deviceCode: external_exports.string().min(1).max(200) });
19855
-
19856
- // ../shared/src/errors.ts
19857
- var ERROR_STATUS = {
19858
- UNAUTHORIZED: 401,
19859
- FORBIDDEN: 403,
19860
- NOT_FOUND: 404,
19861
- VALIDATION_FAILED: 400,
19862
- INVALID_MANIFEST: 400,
19863
- INVALID_PATH: 400,
19864
- TOO_LARGE: 413,
19865
- TOO_MANY_FILES: 413,
19866
- ENTRY_MISSING: 400,
19867
- SLUG_TAKEN: 409,
19868
- GAME_LIMIT_REACHED: 403,
19869
- UPLOAD_INCOMPLETE: 409,
19870
- VERSION_NOT_UPLOADING: 409,
19871
- NOT_REMIXABLE: 403,
19872
- RATE_LIMITED: 429,
19873
- // Device-code login polling (RFC 8628 semantics)
19874
- AUTHORIZATION_PENDING: 400,
19875
- SLOW_DOWN: 400,
19876
- EXPIRED_TOKEN: 400,
19877
- ACCESS_DENIED: 400,
19878
- INTERNAL_ERROR: 500
19829
+ var RATE_LIMITS = {
19830
+ /** New accounts, site-wide. */
19831
+ signUp: { limit: 30, windowSeconds: 60 * 60 },
19832
+ /** Versions started (each publish starts one). */
19833
+ publish: { limit: 30, windowSeconds: 60 * 60 },
19834
+ /** Finalize calls, including retries. */
19835
+ finalize: { limit: 60, windowSeconds: 60 * 60 },
19836
+ comment: { limit: 10, windowSeconds: 10 * 60 },
19837
+ /** Likes and unlikes. */
19838
+ like: { limit: 60, windowSeconds: 10 * 60 },
19839
+ report: { limit: 10, windowSeconds: 60 * 60 },
19840
+ /** Play beacons; over the limit a play just isn't counted. */
19841
+ play: { limit: 120, windowSeconds: 60 * 60 },
19842
+ /** Connection reports per reporter (S6); over it reports are dropped. */
19843
+ connectionReport: { limit: 60, windowSeconds: 60 * 60 },
19844
+ /** AI reviews of a creator's versions per day (S5); over it a version isn't reviewed. */
19845
+ review: { limit: 20, windowSeconds: 24 * 60 * 60 }
19879
19846
  };
19880
- function isApiErrorBody(value) {
19881
- if (typeof value !== "object" || value === null || !("error" in value)) return false;
19882
- const { error: error62 } = value;
19883
- if (typeof error62 !== "object" || error62 === null) return false;
19884
- const { code } = error62;
19885
- return typeof code === "string" && code in ERROR_STATUS;
19886
- }
19887
-
19888
- // ../shared/src/ids.ts
19889
- var PUBLIC_ID_LENGTH = 10;
19890
- var PUBLIC_ID_PATTERN = new RegExp(`^[a-z0-9]{${PUBLIC_ID_LENGTH}}$`);
19847
+ var THUMBNAIL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
19891
19848
 
19892
19849
  // ../shared/src/paths.ts
19893
19850
  var IGNORED_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "__MACOSX"]);
@@ -19929,6 +19886,7 @@ function commonTopFolder(paths) {
19929
19886
  }
19930
19887
  return top;
19931
19888
  }
19889
+ var SHA256_PATTERN = /^[0-9a-f]{64}$/;
19932
19890
  function planUpload(entries, options = {}) {
19933
19891
  const normalized = entries.map((entry) => ({ ...entry, path: normalizePath(entry.path) }));
19934
19892
  const macJunk = normalized.filter((entry) => entry.path.startsWith("__MACOSX/"));
@@ -19945,7 +19903,7 @@ function planUpload(entries, options = {}) {
19945
19903
  const issue2 = validatePath(path2);
19946
19904
  if (issue2) plan.invalid.push({ path: path2, issue: issue2 });
19947
19905
  else if (isIgnoredPath(path2, options)) plan.ignored.push(path2);
19948
- else plan.files.push({ path: path2, size: entry.size });
19906
+ else plan.files.push({ ...entry, path: path2 });
19949
19907
  }
19950
19908
  return plan;
19951
19909
  }
@@ -19961,32 +19919,108 @@ var relativePath = (what) => external_exports.string().transform(normalizePath).
19961
19919
  `${what} must be a relative path inside the project`
19962
19920
  );
19963
19921
  var tagSchema = external_exports.string().min(2).max(24).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "tags are lowercase words, e.g. puzzle or tower-defense");
19922
+ var gameFieldSchemas = {
19923
+ title: external_exports.string().trim().min(1).max(60),
19924
+ description: external_exports.string().trim().max(500),
19925
+ engine: external_exports.enum(ENGINES),
19926
+ madeWith: external_exports.array(external_exports.string().trim().min(1).max(40)).max(5),
19927
+ tags: external_exports.array(tagSchema).max(5),
19928
+ input: external_exports.array(external_exports.enum(INPUTS)),
19929
+ orientation: external_exports.enum(ORIENTATIONS),
19930
+ aspectRatio: external_exports.string().regex(/^(fill|\d{1,2}:\d{1,2})$/, 'use "W:H" like "16:9", or "fill"'),
19931
+ remixable: external_exports.boolean(),
19932
+ sourceUrl: external_exports.url({ protocol: /^https$/ })
19933
+ };
19964
19934
  var manifestSchema = external_exports.strictObject({
19965
19935
  $schema: external_exports.string().optional(),
19966
19936
  slug: slugSchema,
19967
- title: external_exports.string().trim().min(1).max(60),
19968
- description: external_exports.string().trim().max(500).default(""),
19937
+ title: gameFieldSchemas.title,
19938
+ description: gameFieldSchemas.description.default(""),
19969
19939
  buildDir: external_exports.string().default(".").transform((dir) => normalizePath(dir).replace(/\/+$/, "") || ".").refine(
19970
19940
  (dir) => dir === "." || validatePath(dir) === null,
19971
19941
  "buildDir must be a folder inside the project"
19972
19942
  ),
19973
19943
  entry: relativePath("entry").refine((path2) => /\.html?$/i.test(path2), "entry must be an .html file").default("index.html"),
19974
- engine: external_exports.enum(ENGINES).default("other"),
19975
- madeWith: external_exports.array(external_exports.string().trim().min(1).max(40)).max(5).default([]),
19976
- tags: external_exports.array(tagSchema).max(5).default([]),
19977
- input: external_exports.array(external_exports.enum(INPUTS)).default([]),
19978
- orientation: external_exports.enum(ORIENTATIONS).default("any"),
19979
- aspectRatio: external_exports.string().regex(/^(fill|\d{1,2}:\d{1,2})$/, 'use "W:H" like "16:9", or "fill"').default("16:9"),
19944
+ engine: gameFieldSchemas.engine.default("other"),
19945
+ madeWith: gameFieldSchemas.madeWith.default([]),
19946
+ tags: gameFieldSchemas.tags.default([]),
19947
+ input: gameFieldSchemas.input.default([]),
19948
+ orientation: gameFieldSchemas.orientation.default("any"),
19949
+ aspectRatio: gameFieldSchemas.aspectRatio.default("16:9"),
19980
19950
  thumbnail: relativePath("thumbnail").refine(
19981
19951
  (path2) => THUMBNAIL_EXTENSIONS.some((ext) => path2.toLowerCase().endsWith(`.${ext}`)),
19982
19952
  `thumbnail must be one of: ${THUMBNAIL_EXTENSIONS.join(", ")}`
19983
19953
  ).optional(),
19984
19954
  howItWasMade: relativePath("howItWasMade").refine((path2) => path2.toLowerCase().endsWith(".md"), "howItWasMade must be a .md file").optional(),
19985
- remixable: external_exports.boolean().default(false),
19986
- sourceUrl: external_exports.url({ protocol: /^https$/ }).optional(),
19955
+ remixable: gameFieldSchemas.remixable.default(false),
19956
+ sourceUrl: gameFieldSchemas.sourceUrl.optional(),
19987
19957
  remixedFrom: slugSchema.nullable().optional()
19988
19958
  });
19989
19959
  var MANIFEST_SCHEMA_URL = "https://juicyjuicy.games/schema/juicy.json";
19960
+ function slugify2(name) {
19961
+ const slug = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
19962
+ return slug.length >= 3 ? slug : `${slug || "my"}-game`;
19963
+ }
19964
+ function titleFromName(name) {
19965
+ return name.split(/[-_\s]+/).filter(Boolean).map((word) => word[0]?.toUpperCase() + word.slice(1)).join(" ").slice(0, 60);
19966
+ }
19967
+
19968
+ // ../shared/src/api.ts
19969
+ var PUBLISH_SOURCES = ["cli", "web", "github"];
19970
+ var startVersionRequestSchema = external_exports.object({
19971
+ /** Raw `juicy.json`. Its `slug` must match the URL. */
19972
+ manifest: external_exports.unknown(),
19973
+ /** The build folder after `planUpload`. */
19974
+ files: external_exports.array(
19975
+ external_exports.object({
19976
+ path: external_exports.string().max(1024),
19977
+ size: external_exports.number(),
19978
+ /** Hex SHA-256; R2 refuses an upload that doesn't match it (D15). Optional for older CLIs. */
19979
+ sha256: external_exports.string().regex(SHA256_PATTERN, "sha256 must be 64 hex digits").optional()
19980
+ })
19981
+ ).max(LIMITS.maxFiles * 4),
19982
+ thumbnail: external_exports.object({ sha256: external_exports.string(), size: external_exports.number(), extension: external_exports.string() }).optional(),
19983
+ /** Contents of the manifest's `howItWasMade` markdown file. */
19984
+ howItWasMade: external_exports.string().optional(),
19985
+ /** A note for the version history, e.g. what changed or a commit SHA. */
19986
+ message: external_exports.string().trim().max(200).optional(),
19987
+ source: external_exports.enum(PUBLISH_SOURCES).default("cli")
19988
+ });
19989
+ var updateGameRequestSchema = external_exports.object({
19990
+ slug: slugSchema.optional(),
19991
+ status: external_exports.enum(["published", "unlisted"]).optional(),
19992
+ title: gameFieldSchemas.title.optional(),
19993
+ description: gameFieldSchemas.description.optional(),
19994
+ engine: gameFieldSchemas.engine.optional(),
19995
+ madeWith: gameFieldSchemas.madeWith.optional(),
19996
+ tags: gameFieldSchemas.tags.optional(),
19997
+ input: gameFieldSchemas.input.optional(),
19998
+ orientation: gameFieldSchemas.orientation.optional(),
19999
+ aspectRatio: gameFieldSchemas.aspectRatio.optional(),
20000
+ remixable: gameFieldSchemas.remixable.optional(),
20001
+ sourceUrl: gameFieldSchemas.sourceUrl.nullable().optional(),
20002
+ /** Markdown, ≤ 20 KB. */
20003
+ howItWasMade: external_exports.string().nullable().optional()
20004
+ });
20005
+ var rollbackRequestSchema = external_exports.object({ version: external_exports.number().int().positive() });
20006
+ var postCommentRequestSchema = external_exports.object({
20007
+ body: external_exports.string().trim().min(1, "write something first").max(LIMITS.maxCommentLength, `comments are at most ${LIMITS.maxCommentLength} characters`)
20008
+ });
20009
+ var REPORT_REASONS = [
20010
+ "broken",
20011
+ "harmful",
20012
+ "inappropriate",
20013
+ "copyright",
20014
+ "spam",
20015
+ "other"
20016
+ ];
20017
+ var reportGameRequestSchema = external_exports.object({
20018
+ reason: external_exports.enum(REPORT_REASONS),
20019
+ details: external_exports.string().trim().max(
20020
+ LIMITS.maxReportDetailsLength,
20021
+ `details are at most ${LIMITS.maxReportDetailsLength} characters`
20022
+ ).optional()
20023
+ });
19990
20024
 
19991
20025
  // ../shared/src/tokens.ts
19992
20026
  var TOKEN_PREFIX = "jj_pat_";
@@ -20065,6 +20099,330 @@ function checkThumbnail(extension, size) {
20065
20099
  return null;
20066
20100
  }
20067
20101
 
20102
+ // ../shared/src/checks.ts
20103
+ var MAX_SCAN_BYTES = 20 * 1024 * 1024;
20104
+ var SCANNED_EXTENSIONS = /* @__PURE__ */ new Set(["html", "htm", "js", "mjs", "cjs", "css", "json"]);
20105
+ var EXECUTABLE_EXTENSIONS = [
20106
+ "exe",
20107
+ "dll",
20108
+ "msi",
20109
+ "msix",
20110
+ "bat",
20111
+ "cmd",
20112
+ "com",
20113
+ "scr",
20114
+ "ps1",
20115
+ "vbs",
20116
+ "hta",
20117
+ "sh",
20118
+ "jar",
20119
+ "apk",
20120
+ "ipa",
20121
+ "dmg",
20122
+ "pkg",
20123
+ "deb",
20124
+ "rpm",
20125
+ "appimage"
20126
+ ];
20127
+ var KNOWN_LIBRARY_SHA256 = /* @__PURE__ */ new Set([
20128
+ // Phaser 4.2.1 dist/phaser.min.js and dist/phaser.js, as copied from npm or a CDN.
20129
+ "66348b1b5141e49b7d5ebbe688cddcb502eab1cb00f21c538686a5b2c5abe4de",
20130
+ "eb1bef9b0b35cab1f95e00d14ba728a2b14e6ae1dd9ecfa3b60dc16ca04fa259"
20131
+ ]);
20132
+ function extensionOf(path2) {
20133
+ const name = path2.slice(path2.lastIndexOf("/") + 1);
20134
+ const dot = name.lastIndexOf(".");
20135
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
20136
+ }
20137
+ function isScannedFile(path2) {
20138
+ return SCANNED_EXTENSIONS.has(extensionOf(path2));
20139
+ }
20140
+ var LIMIT_RULES = {
20141
+ TOO_MANY_FILES: "too-many-files",
20142
+ TOO_LARGE: "too-large",
20143
+ INVALID_PATH: "unsafe-path",
20144
+ ENTRY_MISSING: "entry-missing",
20145
+ VALIDATION_FAILED: "bad-file-size"
20146
+ };
20147
+ function checkGameFiles(files, entry) {
20148
+ const findings = checkVersionFiles(files, entry).map((issue2) => ({
20149
+ rule: LIMIT_RULES[issue2.code] ?? "limits",
20150
+ severity: "error",
20151
+ ...issue2
20152
+ }));
20153
+ for (const { path: path2 } of files) {
20154
+ const extension = extensionOf(path2);
20155
+ if (EXECUTABLE_EXTENSIONS.includes(extension)) {
20156
+ findings.push({
20157
+ rule: "executable",
20158
+ severity: "error",
20159
+ code: "UNSAFE_CONTENT",
20160
+ message: `.${extension} files are programs or installers; games can't include them`,
20161
+ path: path2
20162
+ });
20163
+ }
20164
+ }
20165
+ return findings;
20166
+ }
20167
+ var KEY_MESSAGE = (what) => (found) => `contains what looks like ${what} (${found}). Every player can read a game's files: remove it, and revoke the key`;
20168
+ var CONTENT_RULES = [
20169
+ {
20170
+ rule: "secret-key",
20171
+ severity: "error",
20172
+ pattern: /\bsk-ant-[a-z]+\d*-[A-Za-z0-9_-]{32,}/,
20173
+ message: KEY_MESSAGE("an Anthropic API key")
20174
+ },
20175
+ {
20176
+ rule: "secret-key",
20177
+ severity: "error",
20178
+ pattern: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{32,}|\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/,
20179
+ message: KEY_MESSAGE("an OpenAI API key")
20180
+ },
20181
+ {
20182
+ rule: "secret-key",
20183
+ severity: "error",
20184
+ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
20185
+ message: KEY_MESSAGE("an AWS access key")
20186
+ },
20187
+ {
20188
+ rule: "secret-key",
20189
+ severity: "error",
20190
+ pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{60,}/,
20191
+ message: KEY_MESSAGE("a GitHub token")
20192
+ },
20193
+ {
20194
+ rule: "secret-key",
20195
+ severity: "error",
20196
+ pattern: /\b[rs]k_live_[A-Za-z0-9]{20,}/,
20197
+ message: KEY_MESSAGE("a live Stripe secret key")
20198
+ },
20199
+ {
20200
+ rule: "secret-key",
20201
+ severity: "error",
20202
+ pattern: new RegExp(`\\b${TOKEN_PREFIX}[0-9A-Za-z]{${TOKEN_SECRET_LENGTH}}\\b`),
20203
+ message: KEY_MESSAGE("a JuicyJuicy API token")
20204
+ },
20205
+ {
20206
+ // The key itself must follow: crypto libraries contain the bare header as a string.
20207
+ rule: "secret-key",
20208
+ severity: "error",
20209
+ pattern: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----(?:\s|\\[rn])*[A-Za-z0-9+/]{40}/,
20210
+ message: KEY_MESSAGE("a private key")
20211
+ },
20212
+ {
20213
+ rule: "crypto-miner",
20214
+ severity: "error",
20215
+ pattern: /stratum\+(?:tcp|ssl|tls):\/\/|\bCoinHive\.(?:Anonymous|User|Token)\b|\bcoin-?hive\.com\b|\bauthedmine\.com\b|\bcrypto-loot\.(?:com|org)\b|\bCRLT\.Anonymous\b|\bcoinimp\.com\b|\bwebminepool\.com\b|\bminero\.cc\b|\bdeepMiner\.(?:Anonymous|Init)\b|\bcryptonight(?:_wasm|\.wasm)\b/,
20216
+ message: (found) => `contains crypto-mining code (${found}). Mining in players' browsers isn't allowed`
20217
+ },
20218
+ {
20219
+ rule: "google-api-key",
20220
+ severity: "warning",
20221
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
20222
+ message: (found) => `contains a Google API key (${found}). Firebase web keys are public by design, but make sure the project's security rules and the key's restrictions don't let players do more than the game needs`
20223
+ },
20224
+ {
20225
+ rule: "threads",
20226
+ severity: "warning",
20227
+ pattern: /\bnew SharedArrayBuffer\b|\bAtomics\.wait\(/,
20228
+ message: () => "uses SharedArrayBuffer or Atomics.wait, which need cross-origin isolation that JuicyJuicy doesn't offer. A threaded build won't start: export without threads"
20229
+ },
20230
+ {
20231
+ rule: "popup",
20232
+ severity: "warning",
20233
+ pattern: /\bwindow\.open\(/,
20234
+ message: () => "calls window.open; popups are blocked inside the game frame"
20235
+ },
20236
+ {
20237
+ rule: "top-navigation",
20238
+ severity: "warning",
20239
+ pattern: /\btop\.location\b|\btarget\s*=\s*["']?_top\b/,
20240
+ message: () => "tries to navigate the top page; the game frame doesn't allow that"
20241
+ },
20242
+ {
20243
+ rule: "insecure-url",
20244
+ severity: "warning",
20245
+ pattern: /\b(?:src|href)\s*=\s*["']http:\/\/|\burl\(\s*["']?http:\/\/|\b(?:fetch|import)\(\s*["'`]http:\/\/|["'`]ws:\/\//,
20246
+ message: (found) => `loads something over plain http or ws (${found}); browsers block that on an https page. Use https:// or wss://`
20247
+ },
20248
+ {
20249
+ rule: "localhost-url",
20250
+ severity: "warning",
20251
+ pattern: /["'`(](?:https?|wss?):\/\/(?:localhost|127\.0\.0\.1)(?=[:/"'`)])/,
20252
+ message: (found) => `points at localhost (${found}), which is the player's own computer, not your dev server`
20253
+ },
20254
+ {
20255
+ rule: "password-input",
20256
+ severity: "warning",
20257
+ pattern: /\btype\s*=\s*["']?password\b|\.type\s*=\s*["']password["']/,
20258
+ message: () => "has a password field. A game should never ask for a password: players can't tell a game's form from a phishing page"
20259
+ }
20260
+ ];
20261
+ function shorten(found, rule) {
20262
+ const text2 = found.replace(/\s+/g, " ");
20263
+ if (rule === "secret-key") return `${text2.slice(0, 10)}\u2026`;
20264
+ return text2.length > 60 ? `${text2.slice(0, 57)}\u2026` : text2;
20265
+ }
20266
+ function checkFileContent(path2, text2) {
20267
+ const extension = extensionOf(path2);
20268
+ const findings = [];
20269
+ const reported = /* @__PURE__ */ new Set();
20270
+ for (const rule of CONTENT_RULES) {
20271
+ if (rule.only && !rule.only.includes(extension)) continue;
20272
+ const key = `${rule.rule}:${rule.pattern.source}`;
20273
+ if (reported.has(key)) continue;
20274
+ const match = rule.pattern.exec(text2);
20275
+ if (!match) continue;
20276
+ reported.add(key);
20277
+ findings.push({
20278
+ rule: rule.rule,
20279
+ severity: rule.severity,
20280
+ ...rule.severity === "error" ? { code: "UNSAFE_CONTENT" } : {},
20281
+ message: rule.message(shorten(match[0], rule.rule)),
20282
+ path: path2
20283
+ });
20284
+ }
20285
+ return findings;
20286
+ }
20287
+ var SCRIPT_SRC = /<script\b[^>]*\bsrc\s*=\s*["']?(?:https?:)?\/\/([^/"'\s>]+)/gi;
20288
+ var DYNAMIC_IMPORT = /\bimport\(\s*["'`]https?:\/\/([^/"'`\s]+)/g;
20289
+ function scriptHostsIn(path2, text2) {
20290
+ const hosts = /* @__PURE__ */ new Set();
20291
+ const patterns = ["html", "htm"].includes(extensionOf(path2)) ? [SCRIPT_SRC, DYNAMIC_IMPORT] : [DYNAMIC_IMPORT];
20292
+ for (const pattern of patterns) {
20293
+ for (const match of text2.matchAll(pattern)) {
20294
+ if (match[1]) hosts.add(match[1].toLowerCase());
20295
+ }
20296
+ }
20297
+ return [...hosts];
20298
+ }
20299
+ async function checkGame({
20300
+ files,
20301
+ entry,
20302
+ readText,
20303
+ concurrency = 8
20304
+ }) {
20305
+ const findings = checkGameFiles(files, entry);
20306
+ const hosts = /* @__PURE__ */ new Set();
20307
+ let budget = MAX_SCAN_BYTES;
20308
+ let complete = true;
20309
+ const toScan = [];
20310
+ for (const file2 of files) {
20311
+ if (!isScannedFile(file2.path)) continue;
20312
+ if (file2.sha256 && KNOWN_LIBRARY_SHA256.has(file2.sha256)) continue;
20313
+ if (file2.size > budget) {
20314
+ complete = false;
20315
+ continue;
20316
+ }
20317
+ budget -= file2.size;
20318
+ toScan.push(file2);
20319
+ }
20320
+ const perFile = new Array(toScan.length);
20321
+ let next = 0;
20322
+ async function worker() {
20323
+ while (next < toScan.length) {
20324
+ const index = next++;
20325
+ const { path: path2 } = toScan[index];
20326
+ const text2 = await readText(path2);
20327
+ if (text2 === null) {
20328
+ perFile[index] = [];
20329
+ continue;
20330
+ }
20331
+ perFile[index] = checkFileContent(path2, text2);
20332
+ for (const host of scriptHostsIn(path2, text2)) hosts.add(host);
20333
+ }
20334
+ }
20335
+ await Promise.all(Array.from({ length: Math.min(concurrency, toScan.length) }, worker));
20336
+ findings.push(...perFile.flat());
20337
+ return { findings, scriptHosts: [...hosts].sort(), complete };
20338
+ }
20339
+
20340
+ // ../shared/src/device.ts
20341
+ var DEVICE_LOGIN = {
20342
+ /** How long a code can be approved. */
20343
+ expiresInSeconds: 10 * 60,
20344
+ /** Minimum seconds between polls; polling faster answers SLOW_DOWN. */
20345
+ intervalSeconds: 5,
20346
+ /** RFC 8628: after SLOW_DOWN, the client adds this many seconds to its interval. */
20347
+ slowDownSeconds: 5,
20348
+ maxClientNameLength: 60
20349
+ };
20350
+ var deviceCodeRequestSchema = external_exports.object({
20351
+ /** Shown on the approval page and used as the token's name, e.g. "juicyjuicy CLI on Lukasz-PC". */
20352
+ clientName: external_exports.string().trim().min(1).max(DEVICE_LOGIN.maxClientNameLength).optional()
20353
+ });
20354
+ var deviceTokenRequestSchema = external_exports.object({ deviceCode: external_exports.string().min(1).max(200) });
20355
+
20356
+ // ../shared/src/errors.ts
20357
+ var ERROR_STATUS = {
20358
+ UNAUTHORIZED: 401,
20359
+ FORBIDDEN: 403,
20360
+ NOT_FOUND: 404,
20361
+ VALIDATION_FAILED: 400,
20362
+ INVALID_MANIFEST: 400,
20363
+ INVALID_PATH: 400,
20364
+ TOO_LARGE: 413,
20365
+ TOO_MANY_FILES: 413,
20366
+ ENTRY_MISSING: 400,
20367
+ SLUG_TAKEN: 409,
20368
+ GAME_LIMIT_REACHED: 403,
20369
+ UPLOAD_INCOMPLETE: 409,
20370
+ VERSION_NOT_UPLOADING: 409,
20371
+ NOT_REMIXABLE: 403,
20372
+ /** A game check (SECURITY-PLAN S2) found something that can't be published: a key, a program, a miner. */
20373
+ UNSAFE_CONTENT: 400,
20374
+ RATE_LIMITED: 429,
20375
+ /** An admin paused all publishing (PUBLISHING_PAUSED, the incident switch). Retry later. */
20376
+ PUBLISHING_PAUSED: 503,
20377
+ // Device-code login polling (RFC 8628 semantics)
20378
+ AUTHORIZATION_PENDING: 400,
20379
+ SLOW_DOWN: 400,
20380
+ EXPIRED_TOKEN: 400,
20381
+ ACCESS_DENIED: 400,
20382
+ INTERNAL_ERROR: 500
20383
+ };
20384
+ function isApiErrorBody(value) {
20385
+ if (typeof value !== "object" || value === null || !("error" in value)) return false;
20386
+ const { error: error62 } = value;
20387
+ if (typeof error62 !== "object" || error62 === null) return false;
20388
+ const { code } = error62;
20389
+ return typeof code === "string" && code in ERROR_STATUS;
20390
+ }
20391
+
20392
+ // ../shared/src/ids.ts
20393
+ var PUBLIC_ID_LENGTH = 10;
20394
+ var PUBLIC_ID_PATTERN = new RegExp(`^[a-z0-9]{${PUBLIC_ID_LENGTH}}$`);
20395
+
20396
+ // ../shared/src/license.ts
20397
+ var REMIX_LICENSE_NAME = "JuicyJuicy remix license";
20398
+ var REMIX_LICENSE = "Members may download this game's files and publish a changed version of it on JuicyJuicy, credited to the original. It doesn't allow publishing the game or a remix of it anywhere else, or passing the files on. Turning Remixable off stops new remix downloads; remixes already published stay up.";
20399
+
20400
+ // ../shared/src/security-headers.ts
20401
+ var DENIED_FEATURES = [
20402
+ "camera",
20403
+ "microphone",
20404
+ "geolocation",
20405
+ "display-capture",
20406
+ "payment",
20407
+ "usb",
20408
+ "serial",
20409
+ "hid",
20410
+ "bluetooth",
20411
+ "midi",
20412
+ "magnetometer",
20413
+ "ambient-light-sensor",
20414
+ "idle-detection",
20415
+ "local-fonts",
20416
+ "window-management",
20417
+ "publickey-credentials-get",
20418
+ "publickey-credentials-create",
20419
+ "otp-credentials",
20420
+ "identity-credentials-get",
20421
+ "browsing-topics",
20422
+ "attribution-reporting"
20423
+ ];
20424
+ var PERMISSIONS_POLICY = DENIED_FEATURES.map((feature) => `${feature}=()`).join(", ");
20425
+
20068
20426
  // ../../node_modules/.pnpm/commander@15.0.0/node_modules/commander/lib/error.js
20069
20427
  var CommanderError = class extends Error {
20070
20428
  /**
@@ -23436,7 +23794,7 @@ var program = new Command();
23436
23794
  // package.json
23437
23795
  var package_default = {
23438
23796
  name: "juicyjuicy",
23439
- version: "0.2.0",
23797
+ version: "0.3.0",
23440
23798
  description: "Publish AI-made browser games to juicyjuicy.games",
23441
23799
  type: "module",
23442
23800
  license: "MIT",
@@ -23444,6 +23802,9 @@ var package_default = {
23444
23802
  "games",
23445
23803
  "publish",
23446
23804
  "claude",
23805
+ "codex",
23806
+ "copilot",
23807
+ "agent-skills",
23447
23808
  "cli"
23448
23809
  ],
23449
23810
  homepage: "https://juicyjuicy.games/docs/publish",
@@ -24390,7 +24751,12 @@ ${i2}
24390
24751
  // src/output.ts
24391
24752
  var EXIT = { ok: 0, validation: 1, auth: 2, network: 3 };
24392
24753
  var AUTH_CODES = /* @__PURE__ */ new Set(["UNAUTHORIZED", "EXPIRED_TOKEN", "ACCESS_DENIED"]);
24393
- var NETWORK_CODES = /* @__PURE__ */ new Set(["NETWORK_ERROR", "INTERNAL_ERROR", "RATE_LIMITED"]);
24754
+ var NETWORK_CODES = /* @__PURE__ */ new Set([
24755
+ "NETWORK_ERROR",
24756
+ "INTERNAL_ERROR",
24757
+ "RATE_LIMITED",
24758
+ "PUBLISHING_PAUSED"
24759
+ ]);
24394
24760
  function exitCodeFor(code) {
24395
24761
  if (AUTH_CODES.has(code)) return EXIT.auth;
24396
24762
  if (NETWORK_CODES.has(code)) return EXIT.network;
@@ -24554,16 +24920,16 @@ function openBrowser(url2) {
24554
24920
  // Not `cmd /c start`: cmd would treat `&` in the URL as a command separator.
24555
24921
  ["rundll32", ["url.dll,FileProtocolHandler", url2]]
24556
24922
  ) : process.platform === "darwin" ? ["open", [url2]] : ["xdg-open", [url2]];
24557
- return new Promise((resolve5) => {
24923
+ return new Promise((resolve6) => {
24558
24924
  try {
24559
24925
  const child = spawn(command2, args, { stdio: "ignore", detached: true });
24560
- child.once("error", () => resolve5(false));
24926
+ child.once("error", () => resolve6(false));
24561
24927
  child.once("spawn", () => {
24562
24928
  child.unref();
24563
- resolve5(true);
24929
+ resolve6(true);
24564
24930
  });
24565
24931
  } catch {
24566
- resolve5(false);
24932
+ resolve6(false);
24567
24933
  }
24568
24934
  });
24569
24935
  }
@@ -24770,7 +25136,7 @@ async function loadManifest(dir) {
24770
25136
  const raw = await readRawManifest(dir);
24771
25137
  if (!raw) {
24772
25138
  throw new CliError("INVALID_MANIFEST", `There's no ${MANIFEST_FILE} in ${dir}.`, {
24773
- hint: "Run `npx juicyjuicy init` to create one. Claude: `npx -y juicyjuicy@latest setup` installs the JuicyJuicy skill, which has the whole publishing procedure."
25139
+ hint: "Run `npx juicyjuicy init` to create one. AI agents: `npx -y juicyjuicy@latest setup` installs the JuicyJuicy skill, which has the whole publishing procedure."
24774
25140
  });
24775
25141
  }
24776
25142
  const parsed = manifestSchema.safeParse(raw);
@@ -24814,7 +25180,9 @@ async function checkProject(dir, options = {}) {
24814
25180
  thumbnail: null,
24815
25181
  howItWasMade: null,
24816
25182
  issues: [],
24817
- warnings: []
25183
+ warnings: [],
25184
+ findings: [],
25185
+ scriptHosts: []
24818
25186
  };
24819
25187
  if (!(await stat(buildDir).catch(() => null))?.isDirectory()) {
24820
25188
  check3.issues.push({
@@ -24838,7 +25206,19 @@ async function checkProject(dir, options = {}) {
24838
25206
  }
24839
25207
  check3.files = plan.files;
24840
25208
  check3.totalBytes = plan.files.reduce((sum, file2) => sum + file2.size, 0);
24841
- check3.issues.push(...checkVersionFiles(plan.files, manifest.entry));
25209
+ const result = await checkGame({
25210
+ files: plan.files,
25211
+ entry: manifest.entry,
25212
+ readText: (path2) => readFile2(join3(buildDir, path2), "utf8").catch(() => null)
25213
+ });
25214
+ for (const { severity, code, ...finding } of result.findings) {
25215
+ if (severity === "error") check3.issues.push({ code: code ?? "UNSAFE_CONTENT", ...finding });
25216
+ else {
25217
+ check3.findings.push({ severity, ...finding });
25218
+ check3.warnings.push(`${finding.path ? `${finding.path}: ` : ""}${finding.message}`);
25219
+ }
25220
+ }
25221
+ check3.scriptHosts = result.scriptHosts;
24842
25222
  }
24843
25223
  if (manifest.thumbnail) {
24844
25224
  const file2 = await projectFile(dir, manifest.thumbnail);
@@ -24894,6 +25274,19 @@ async function projectFile(dir, path2) {
24894
25274
  async function sha256File(absolute) {
24895
25275
  return createHash("sha256").update(await readFile2(absolute)).digest("hex");
24896
25276
  }
25277
+ async function hashFiles(dir, files, concurrency = 8) {
25278
+ const hashed = new Array(files.length);
25279
+ let next = 0;
25280
+ async function worker() {
25281
+ while (next < files.length) {
25282
+ const index = next++;
25283
+ const file2 = files[index];
25284
+ hashed[index] = { ...file2, sha256: await sha256File(join3(dir, file2.path)) };
25285
+ }
25286
+ }
25287
+ await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
25288
+ return hashed;
25289
+ }
24897
25290
  async function readPackageJson(dir) {
24898
25291
  try {
24899
25292
  return JSON.parse(await readFile2(join3(dir, "package.json"), "utf8"));
@@ -24980,13 +25373,6 @@ async function firstExisting(dir, names) {
24980
25373
  }
24981
25374
  return void 0;
24982
25375
  }
24983
- function slugify2(name) {
24984
- const slug = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
24985
- return slug.length >= 3 ? slug : `${slug || "my"}-game`;
24986
- }
24987
- function titleFromName(name) {
24988
- return name.split(/[-_\s]+/).filter(Boolean).map((word) => word[0]?.toUpperCase() + word.slice(1)).join(" ").slice(0, 60);
24989
- }
24990
25376
 
24991
25377
  // src/init.ts
24992
25378
  var KEY_ORDER = [
@@ -25080,6 +25466,7 @@ function splitList(value) {
25080
25466
  // src/commands/project.ts
25081
25467
  async function init(out, dir, flags) {
25082
25468
  const { path: path2, created, manifest } = await createManifest(out, resolve2(dir), flags);
25469
+ if (manifest.remixable) out.info(`Remixable grants the ${REMIX_LICENSE_NAME}: ${REMIX_LICENSE}`);
25083
25470
  out.result(
25084
25471
  { path: path2, created, manifest },
25085
25472
  `${created ? "Created" : "Updated"} ${relative2(process.cwd(), path2) || path2}. Next: npx juicyjuicy check`
@@ -25112,7 +25499,9 @@ async function check2(out, dir, options) {
25112
25499
  totalBytes: result.totalBytes,
25113
25500
  thumbnail: result.thumbnail?.path ?? null,
25114
25501
  howItWasMade: result.howItWasMade?.path ?? null,
25115
- warnings: result.warnings
25502
+ warnings: result.warnings,
25503
+ findings: result.findings,
25504
+ scriptHosts: result.scriptHosts
25116
25505
  },
25117
25506
  `${result.manifest.slug} is ready: ${result.files.length} files, ${formatBytes(result.totalBytes)}, entry ${result.manifest.entry}.`
25118
25507
  );
@@ -25166,6 +25555,11 @@ async function uploadFiles(tasks, options = {}) {
25166
25555
  const response = await fetchImpl(task.url, { method: "PUT", body, headers: task.headers });
25167
25556
  if (response.ok) return;
25168
25557
  failure2 = `storage answered ${response.status}`;
25558
+ if (response.status === 400 && (await response.text()).includes("BadDigest")) {
25559
+ throw new CliError("UPLOAD_INCOMPLETE", `${task.path} changed while publishing.`, {
25560
+ hint: "Wait for your build to finish, then publish again."
25561
+ });
25562
+ }
25169
25563
  if (!RETRYABLE_STATUS.has(response.status)) {
25170
25564
  throw new CliError("UPLOAD_INCOMPLETE", `Uploading ${task.path} failed: ${failure2}.`, {
25171
25565
  hint: response.status === 403 ? "The upload link may have expired (they last an hour). Publish again." : void 0
@@ -25220,7 +25614,7 @@ async function publish(out, dir, options) {
25220
25614
  const { thumbnail, howItWasMade } = checked;
25221
25615
  const request = {
25222
25616
  manifest: checked.rawManifest,
25223
- files: checked.files,
25617
+ files: await hashFiles(checked.buildDir, checked.files),
25224
25618
  thumbnail: thumbnail ? {
25225
25619
  sha256: await sha256File(thumbnail.absolute),
25226
25620
  size: thumbnail.size,
@@ -25316,47 +25710,283 @@ async function open2(out, slug, options) {
25316
25710
  out.result({ url: url2, opened }, opened ? `Opened ${url2}` : url2);
25317
25711
  }
25318
25712
 
25713
+ // src/commands/remix.ts
25714
+ import { readdir as readdir2 } from "fs/promises";
25715
+ import { join as join6, relative as relative4, resolve as resolve4 } from "path";
25716
+
25717
+ // src/download.ts
25718
+ import { createHash as createHash2 } from "crypto";
25719
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
25720
+ import { dirname as dirname2 } from "path";
25721
+ import { setTimeout as sleep3 } from "timers/promises";
25722
+ var RETRYABLE_STATUS2 = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
25723
+ async function downloadFiles(tasks, options = {}) {
25724
+ const {
25725
+ concurrency = 6,
25726
+ retries = 3,
25727
+ backoffMs = (attempt) => 500 * 2 ** (attempt - 1),
25728
+ fetchImpl = fetch,
25729
+ onProgress
25730
+ } = options;
25731
+ const progress = {
25732
+ files: 0,
25733
+ totalFiles: tasks.length,
25734
+ bytes: 0,
25735
+ totalBytes: tasks.reduce((sum, task) => sum + task.size, 0)
25736
+ };
25737
+ async function fetchOne(task) {
25738
+ for (let attempt = 0; ; attempt++) {
25739
+ if (attempt > 0) await sleep3(backoffMs(attempt));
25740
+ let failure2;
25741
+ try {
25742
+ const response = await fetchImpl(task.url);
25743
+ if (response.ok) return new Uint8Array(await response.arrayBuffer());
25744
+ failure2 = `storage answered ${response.status}`;
25745
+ if (!RETRYABLE_STATUS2.has(response.status)) {
25746
+ throw new CliError("NETWORK_ERROR", `Downloading ${task.path} failed: ${failure2}.`, {
25747
+ hint: response.status === 403 ? "The download links may have expired (they last an hour). Run remix again." : void 0
25748
+ });
25749
+ }
25750
+ } catch (error62) {
25751
+ if (error62 instanceof CliError) throw error62;
25752
+ failure2 = error62 instanceof Error ? error62.message : String(error62);
25753
+ }
25754
+ if (attempt >= retries) {
25755
+ throw new CliError(
25756
+ "NETWORK_ERROR",
25757
+ `Downloading ${task.path} failed after ${retries + 1} tries: ${failure2}.`
25758
+ );
25759
+ }
25760
+ }
25761
+ }
25762
+ async function downloadOne(task) {
25763
+ const bytes = await fetchOne(task);
25764
+ const sha256 = createHash2("sha256").update(bytes).digest("hex");
25765
+ if (bytes.length !== task.size || task.sha256 && sha256 !== task.sha256) {
25766
+ throw new CliError(
25767
+ "UPLOAD_INCOMPLETE",
25768
+ `${task.path} isn't the file that was published (its size or SHA-256 differs).`,
25769
+ { hint: "Run remix again; if it keeps happening, tell an admin." }
25770
+ );
25771
+ }
25772
+ await mkdir2(dirname2(task.absolute), { recursive: true });
25773
+ await writeFile3(task.absolute, bytes);
25774
+ progress.files++;
25775
+ progress.bytes += task.size;
25776
+ onProgress?.({ ...progress });
25777
+ }
25778
+ let next = 0;
25779
+ async function worker() {
25780
+ while (next < tasks.length) {
25781
+ await downloadOne(tasks[next++]);
25782
+ }
25783
+ }
25784
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
25785
+ }
25786
+
25787
+ // src/commands/remix.ts
25788
+ async function remix(out, original, dir, options) {
25789
+ const server = apiUrl();
25790
+ const auth = await ensureLogin(out, server);
25791
+ const api = new ApiClient(server, auth.token);
25792
+ const source = await api.get(`/games/${encodeURIComponent(original)}/remix`);
25793
+ const { game, license } = source;
25794
+ out.event("license", { ...license });
25795
+ out.info(`${license.name}: ${license.text}`);
25796
+ const slug = await chooseSlug(out, game.slug, options.slug);
25797
+ const root = resolve4(dir ?? slug);
25798
+ await ensureEmptyFolder(root);
25799
+ const downloads = [...source.files, ...source.thumbnail ? [source.thumbnail] : []];
25800
+ for (const file2 of downloads) {
25801
+ const issue2 = validatePath(file2.path);
25802
+ if (issue2) throw new CliError("INVALID_PATH", `The server sent a bad path: ${file2.path}`);
25803
+ }
25804
+ const totalBytes = downloads.reduce((sum, file2) => sum + file2.size, 0);
25805
+ const bar = out.interactive ? spinner() : null;
25806
+ bar?.start(`Downloading ${downloads.length} files (${formatBytes(totalBytes)})`);
25807
+ let lastEvent = 0;
25808
+ try {
25809
+ await downloadFiles(
25810
+ downloads.map((file2) => ({ ...file2, absolute: join6(root, file2.path) })),
25811
+ {
25812
+ onProgress: (progress) => {
25813
+ bar?.message(
25814
+ `Downloading ${progress.files}/${progress.totalFiles} files (${formatBytes(progress.bytes)} of ${formatBytes(progress.totalBytes)})`
25815
+ );
25816
+ const now = Date.now();
25817
+ if (now - lastEvent > 500 || progress.files === progress.totalFiles) {
25818
+ lastEvent = now;
25819
+ out.event("download_progress", { ...progress });
25820
+ }
25821
+ }
25822
+ }
25823
+ );
25824
+ } catch (error62) {
25825
+ bar?.error("Download failed");
25826
+ throw error62;
25827
+ }
25828
+ bar?.stop(`Downloaded ${downloads.length} files (${formatBytes(totalBytes)})`);
25829
+ const manifest = remixManifest(source, slug);
25830
+ await writeManifest(root, manifest);
25831
+ const shown = relative4(process.cwd(), root) || ".";
25832
+ const next = [
25833
+ `Work in ${root}. If the user hasn't said what to change, ask them.`,
25834
+ "After each change, ask the user whether to change something else or publish it to JuicyJuicy.",
25835
+ `Before publishing, give it its own "title" in juicy.json (just the remix's name, e.g. "Goose Custom Skins": its page and tile already say Remix of "${game.title}") and a new "description", then run npx juicyjuicy publish.`,
25836
+ "After publishing, give the user the link and ask again: change something else (publishing again makes a new version) or stop."
25837
+ ].join(" ");
25838
+ out.result(
25839
+ {
25840
+ dir: root,
25841
+ slug,
25842
+ remixedFrom: game.slug,
25843
+ title: manifest.title,
25844
+ files: downloads.length,
25845
+ sourceUrl: game.sourceUrl,
25846
+ license,
25847
+ next
25848
+ },
25849
+ [
25850
+ `Remixed ${game.title} by @${game.owner} into ${shown}/ as "${slug}".`,
25851
+ `Open ${shown} in your AI agent and describe your changes. Before publishing, give it its own title in juicy.json (it's shown as Remix of "${game.title}").`,
25852
+ "Then publish with: npx juicyjuicy publish",
25853
+ ...game.sourceUrl ? [
25854
+ `These are the built files. The original's source code is public: git clone ${game.sourceUrl}`
25855
+ ] : [],
25856
+ "Remixes are published on JuicyJuicy only (see the license above)."
25857
+ ].join("\n")
25858
+ );
25859
+ }
25860
+ function remixManifest(source, slug) {
25861
+ const { sourceUrl: _theirs, ...metadata } = source.manifest;
25862
+ const manifest = {
25863
+ $schema: MANIFEST_SCHEMA_URL,
25864
+ slug,
25865
+ ...metadata,
25866
+ // The original's title until the remixer names theirs; the "Remix of" badge shows the lineage.
25867
+ title: source.game.title,
25868
+ remixable: false,
25869
+ remixedFrom: source.game.slug
25870
+ };
25871
+ const parsed = manifestSchema.safeParse(manifest);
25872
+ if (!parsed.success) {
25873
+ throw new CliError("INVALID_MANIFEST", "The remix's juicy.json came out invalid.", {
25874
+ details: parsed.error.issues.map((issue2) => ({
25875
+ path: issue2.path.join("."),
25876
+ message: issue2.message
25877
+ }))
25878
+ });
25879
+ }
25880
+ return manifest;
25881
+ }
25882
+ async function chooseSlug(out, original, given) {
25883
+ const fallback = defaultRemixSlug(original);
25884
+ const check3 = (value) => {
25885
+ const result = slugSchema.safeParse(value);
25886
+ if (!result.success) return result.error.issues[0]?.message ?? "not a valid slug";
25887
+ if (value === original) return "pick a new slug: this one belongs to the original";
25888
+ return void 0;
25889
+ };
25890
+ if (given !== void 0) {
25891
+ const issue2 = check3(given);
25892
+ if (issue2) throw new CliError("VALIDATION_FAILED", `--slug ${given}: ${issue2}`);
25893
+ return given;
25894
+ }
25895
+ if (!out.interactive) return fallback;
25896
+ const answer = await text({
25897
+ message: "Slug for your remix (its URL name)",
25898
+ initialValue: fallback,
25899
+ validate: (value) => check3(value ?? "")
25900
+ });
25901
+ if (isCancel(answer)) throw new CliError("VALIDATION_FAILED", "Cancelled.");
25902
+ return answer;
25903
+ }
25904
+ function defaultRemixSlug(original) {
25905
+ return `${original.slice(0, 40 - "-remix".length).replace(/-+$/, "")}-remix`;
25906
+ }
25907
+ async function ensureEmptyFolder(root) {
25908
+ const entries = await readdir2(root).catch((error62) => {
25909
+ if (error62.code === "ENOENT") return [];
25910
+ throw error62;
25911
+ });
25912
+ if (entries.length > 0) {
25913
+ throw new CliError("VALIDATION_FAILED", `${root} isn't empty.`, {
25914
+ hint: "Pick a new folder: npx juicyjuicy remix <slug> <new-folder>"
25915
+ });
25916
+ }
25917
+ }
25918
+
25319
25919
  // src/commands/setup.ts
25320
- import { join as join7 } from "path";
25920
+ import { join as join8 } from "path";
25321
25921
 
25322
25922
  // src/commands/skill.ts
25323
25923
  import { existsSync as existsSync2 } from "fs";
25324
25924
  import { cp, rm as rm2 } from "fs/promises";
25325
25925
  import { homedir as homedir2 } from "os";
25326
- import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
25926
+ import { dirname as dirname3, join as join7, resolve as resolve5 } from "path";
25327
25927
  import { fileURLToPath } from "url";
25328
25928
  var SKILL_NAME = "juicyjuicy-publish";
25329
25929
  function skillSourceDir() {
25330
- const here = dirname2(fileURLToPath(import.meta.url));
25331
- const candidates = [join6(here, "skill"), resolve4(here, "../../../skill/skills", SKILL_NAME)];
25332
- const found = candidates.find((dir) => existsSync2(join6(dir, "SKILL.md")));
25930
+ const here = dirname3(fileURLToPath(import.meta.url));
25931
+ const candidates = [join7(here, "skill"), resolve5(here, "../../../skill/skills", SKILL_NAME)];
25932
+ const found = candidates.find((dir) => existsSync2(join7(dir, "SKILL.md")));
25333
25933
  if (!found)
25334
25934
  throw new CliError("INTERNAL_ERROR", "This copy of the CLI doesn't include the skill.");
25335
25935
  return found;
25336
25936
  }
25337
- function defaultSkillTarget(env = process.env) {
25338
- return join6(env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir2(), ".claude"), "skills", SKILL_NAME);
25937
+ function skillTargets(env = process.env, home = homedir2()) {
25938
+ const claude = env.CLAUDE_CONFIG_DIR?.trim() || join7(home, ".claude");
25939
+ const gemini = join7(home, ".gemini");
25940
+ const shared = {
25941
+ agents: "Codex, GitHub Copilot, Cursor, Gemini CLI, Antigravity and other agents",
25942
+ dir: join7(home, ".agents", "skills")
25943
+ };
25944
+ const own2 = [
25945
+ { agents: "Claude Code", home: claude, running: env.CLAUDECODE === "1" },
25946
+ { agents: "Antigravity CLI", home: join7(gemini, "antigravity-cli"), running: false },
25947
+ { agents: "Antigravity (older versions)", home: join7(gemini, "antigravity"), running: false }
25948
+ ].filter((agent) => agent.running || existsSync2(agent.home)).map((agent) => ({
25949
+ agents: agent.agents,
25950
+ dir: join7(agent.home, "skills"),
25951
+ running: agent.running
25952
+ }));
25953
+ const running = own2.filter((target) => target.running);
25954
+ const rest = own2.filter((target) => !target.running);
25955
+ return [...running, shared, ...rest].map(({ agents, dir }) => ({ agents, dir }));
25339
25956
  }
25340
25957
  async function installSkill(out, options) {
25341
- const { path: path2, updated } = await copySkill(options);
25958
+ const skill = await copySkill(options);
25342
25959
  out.result(
25343
- { path: path2, updated },
25344
- `${updated ? "Updated" : "Installed"} the ${SKILL_NAME} skill in ${path2}. In Claude Code, say "publish this game to JuicyJuicy".`
25960
+ skill,
25961
+ `${skill.updated ? "Updated" : "Installed"} the ${SKILL_NAME} skill ${describeInstalls(skill.installed)}. Ask your AI agent to "publish this game to JuicyJuicy".`
25345
25962
  );
25346
25963
  }
25347
25964
  async function copySkill(options) {
25348
- const path2 = options.dir ? resolve4(options.dir, SKILL_NAME) : defaultSkillTarget();
25349
- const updated = existsSync2(path2);
25350
- await rm2(path2, { recursive: true, force: true });
25351
- await cp(skillSourceDir(), path2, { recursive: true });
25352
- return { path: path2, updated };
25965
+ const targets = options.dir ? [{ agents: "", dir: resolve5(options.dir) }] : skillTargets();
25966
+ const installed = [];
25967
+ for (const target of targets) {
25968
+ const path2 = join7(target.dir, SKILL_NAME);
25969
+ const updated = existsSync2(path2);
25970
+ await rm2(path2, { recursive: true, force: true });
25971
+ await cp(skillSourceDir(), path2, { recursive: true });
25972
+ installed.push({ path: path2, agents: target.agents, updated });
25973
+ }
25974
+ const [first] = installed;
25975
+ return { path: first.path, updated: first.updated, installed };
25976
+ }
25977
+ function describeInstalls(installed) {
25978
+ if (installed.length === 1 && !installed[0]?.agents) return `in ${installed[0]?.path}`;
25979
+ const parts = installed.map(({ path: path2, agents }) => `${agents} (${path2})`);
25980
+ return `for ${parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0]}`;
25353
25981
  }
25354
25982
 
25355
25983
  // src/commands/setup.ts
25356
25984
  async function setup(out, options) {
25357
25985
  const skill = await copySkill(options);
25358
25986
  out.event("skill_installed", skill);
25359
- out.success(`${skill.updated ? "Updated" : "Installed"} the Claude skill in ${skill.path}`);
25987
+ out.success(
25988
+ `${skill.updated ? "Updated" : "Installed"} the JuicyJuicy skill ${describeInstalls(skill.installed)}`
25989
+ );
25360
25990
  const server = apiUrl();
25361
25991
  let user = await currentUser(server);
25362
25992
  if (user) out.success(`Already logged in as @${user.username}`);
@@ -25367,9 +25997,9 @@ async function setup(out, options) {
25367
25997
  user,
25368
25998
  apiUrl: server,
25369
25999
  skill,
25370
- next: `Read ${join7(skill.path, "SKILL.md")} and follow it to publish the game.`
26000
+ next: `Read ${join8(skill.path, "SKILL.md")} and follow it to publish the game.`
25371
26001
  },
25372
- `All set, @${user.username}. Open Claude Code in your game's folder and say "${prompt}". (If Claude doesn't know the ${SKILL_NAME} skill yet, start a new session.)`
26002
+ `All set, @${user.username}. Open your AI agent in your game's folder and say "${prompt}". (If it doesn't know the ${SKILL_NAME} skill yet, start a new session.)`
25373
26003
  );
25374
26004
  }
25375
26005
  async function currentUser(server) {
@@ -25390,7 +26020,7 @@ function createProgram() {
25390
26020
  "after",
25391
26021
  "\nEnvironment:\n JUICY_TOKEN token to use instead of the saved login (CI)\n JUICY_API_URL server to talk to (default https://juicyjuicy.games)"
25392
26022
  );
25393
- command(program2, "setup", "Set up once: install the Claude skill and log in").option("--dir <dir>", "install the skill into this skills folder, e.g. .claude/skills").option("--no-browser", "don't open the browser, just print the link").action(run((out, options) => setup(out, options)));
26023
+ command(program2, "setup", "Set up once: install the skill for your AI agents and log in").option("--dir <dir>", "install the skill into only this skills folder, e.g. .agents/skills").option("--no-browser", "don't open the browser, just print the link").action(run((out, options) => setup(out, options)));
25394
26024
  command(program2, "login", "Log in through your browser (device code) and save a token").option("--no-browser", "don't open the browser, just print the link").action(run((out, options) => login(out, options)));
25395
26025
  command(program2, "whoami", "Show who you're logged in as").action(run((out) => whoami(out)));
25396
26026
  command(program2, "logout", "Forget and revoke the saved token").action(run((out) => logout(out)));
@@ -25398,7 +26028,11 @@ function createProgram() {
25398
26028
  "--made-with <list>",
25399
26029
  'models and tools, e.g. "Claude Opus 5.5, Claude Code"',
25400
26030
  splitList
25401
- ).option("--tags <list>", "up to 5 tags, e.g. arcade,space", splitList).addOption(new Option("--input <list>", `controls: ${INPUTS.join(", ")}`).argParser(splitList)).addOption(new Option("--orientation <orientation>").choices(ORIENTATIONS)).option("--aspect-ratio <ratio>", 'like 16:9, or "fill"').option("--thumbnail <file>", "png/jpg/webp in the game folder").option("--remixable", "let others remix it").option("--source-url <url>", "public source code, e.g. a GitHub repo").action(run((out, options, dir) => init(out, dir, options)));
26031
+ ).option("--tags <list>", "up to 5 tags, e.g. arcade,space", splitList).addOption(new Option("--input <list>", `controls: ${INPUTS.join(", ")}`).argParser(splitList)).addOption(new Option("--orientation <orientation>").choices(ORIENTATIONS)).option("--aspect-ratio <ratio>", 'like 16:9, or "fill"').option("--thumbnail <file>", "png/jpg/webp in the game folder").option("--remixable", `let other members remix it on JuicyJuicy only (${REMIX_LICENSE_NAME})`).option("--source-url <url>", "public source code, e.g. a GitHub repo").addHelpText(
26032
+ "after",
26033
+ `
26034
+ ${REMIX_LICENSE_NAME} (--remixable): ${REMIX_LICENSE}`
26035
+ ).action(run((out, options, dir) => init(out, dir, options)));
25402
26036
  command(program2, "check", "Check juicy.json and the build folder without uploading").argument("[dir]", "game folder", ".").option("--include-source-maps", "upload *.map files too").action(
25403
26037
  run((out, options, dir) => check2(out, dir, options))
25404
26038
  );
@@ -25415,8 +26049,17 @@ function createProgram() {
25415
26049
  (out, options, slug) => open2(out, slug, options)
25416
26050
  )
25417
26051
  );
25418
- const skill = program2.command("skill").description("Manage the Claude skill");
25419
- command(skill, "install", "Install the JuicyJuicy skill for Claude (~/.claude/skills)").option("--dir <dir>", "install into this skills folder instead, e.g. .claude/skills").action(run((out, options) => installSkill(out, options)));
26052
+ command(
26053
+ program2,
26054
+ "remix",
26055
+ "Download a remixable game into a new folder to change it and publish it on JuicyJuicy"
26056
+ ).argument("<slug>", "the game to remix, e.g. space-goose").argument("[dir]", "new folder (default: the remix's slug)").option("--slug <slug>", "your remix's slug (default: <slug>-remix)").action(
26057
+ run(
26058
+ (out, options, slug, dir) => remix(out, slug, dir, options)
26059
+ )
26060
+ );
26061
+ const skill = program2.command("skill").description("Manage the JuicyJuicy skill for AI agents");
26062
+ command(skill, "install", "Install the JuicyJuicy skill for the AI agents on this machine").option("--dir <dir>", "install into only this skills folder, e.g. .agents/skills").action(run((out, options) => installSkill(out, options)));
25420
26063
  return program2;
25421
26064
  }
25422
26065
  function command(parent, name, description) {