arcadible 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ arc init star-drift && cd star-drift
35
35
  arc deploy
36
36
  ```
37
37
 
38
- Your repository is the store page: `arcadible.toml` holds the game's settings, `README.md` its title and description, and `.arcadible/` its art. `arc deploy` validates, builds, packages, and publishes it, then waits until the release is live.
38
+ Your repository is the store page: `arcadible.toml` holds the game's settings, `README.md` its title and description, and `.arcadible/` its art. `arc deploy` validates, builds, packages, and publishes it, then waits until the release is live. A new project starts at a prerelease version, `0.1.0-beta.1`, so its first deploy goes to staging: a link to play and share, before players see it.
39
39
 
40
40
  ## Commands
41
41
 
package/bin/arc-build.js CHANGED
@@ -35,6 +35,8 @@ const print = output.print;
35
35
  const relative = path.relative;
36
36
  const resolve = path.resolve;
37
37
  const SpinnerWheel = spinner.SpinnerWheel;
38
+ const warn = output.warn;
39
+ const warning = output.warning;
38
40
 
39
41
  // [declare]
40
42
 
@@ -58,6 +60,8 @@ const start = function (argv) {
58
60
  const root = resolve(process.cwd(), argv.cwd);
59
61
  // the project, parsed once for both steps.
60
62
  let parsed = null;
63
+ // what --json lists under `warnings`.
64
+ let warnings = [];
61
65
  let sw = new SpinnerWheel("Validating... %s");
62
66
  sw.start(json || quiet);
63
67
  return project(root)
@@ -65,23 +69,34 @@ const start = function (argv) {
65
69
  parsed = proj;
66
70
  return validate({ project: parsed, root });
67
71
  })
68
- .then((issues) => {
69
- const count = issues.length;
72
+ .then((validated) => {
73
+ const count = validated.issues.length;
74
+ warnings = validated.warnings;
70
75
  if (count) {
71
76
  throw new ArcError(
72
77
  cfgcode.VALIDATION_FAILED,
73
78
  phrase.issuesFound(count),
74
- { data: { issues } },
79
+ { data: { issues: validated.issues } },
75
80
  );
76
81
  }
77
82
  sw.stop(true);
83
+ warn(warnings, { json, quiet });
78
84
  sw = new SpinnerWheel("Building... %s");
79
85
  sw.start(json || quiet);
80
86
  return build({ project: parsed, root });
81
87
  })
82
88
  .then((artifact) => {
83
89
  if (json) {
84
- print({ artifact });
90
+ const missing = artifact?.missing
91
+ ? [
92
+ warning(
93
+ cfgcode.WARNING.DEPLOY_DIR_NOT_BUILT,
94
+ phrase.buildOutputMissing(relative(root, artifact.missing)),
95
+ "workflow.deploy_dir",
96
+ ),
97
+ ]
98
+ : [];
99
+ print({ artifact, warnings: [...warnings, ...missing] });
85
100
  return;
86
101
  }
87
102
  if (!quiet) {
@@ -101,7 +116,9 @@ const start = function (argv) {
101
116
  log.hs("Nothing to do.");
102
117
  }
103
118
  })
104
- .catch((ex) => abort("Build aborted.", ex, { json, sw }));
119
+ .catch((ex) =>
120
+ abort("Build aborted.", ex, { data: { warnings }, json, sw }),
121
+ );
105
122
  };
106
123
 
107
124
  // [function]
package/bin/arc-deploy.js CHANGED
@@ -41,6 +41,8 @@ const print = output.print;
41
41
  const ProgressBar = progress.ProgressBar;
42
42
  const resolve = path.resolve;
43
43
  const SpinnerWheel = spinner.SpinnerWheel;
44
+ const warn = output.warn;
45
+ const warning = output.warning;
44
46
 
45
47
  // [declare]
46
48
 
@@ -64,7 +66,9 @@ const conf = {
64
66
 
65
67
  // [function]
66
68
 
67
- const handler = function ({ json, pb, quiet, sw }) {
69
+ // `warnings` collects what --json lists, such as a version that couldn't be
70
+ // checked.
71
+ const handler = function ({ json, pb, quiet, sw, warnings }) {
68
72
  return {
69
73
  onAccountRefresh(refresh) {
70
74
  sw.start(json || quiet);
@@ -75,7 +79,10 @@ const handler = function ({ json, pb, quiet, sw }) {
75
79
  pb.start();
76
80
  }
77
81
  },
78
- onLogInfo({ level, message }) {
82
+ onLogInfo({ code, level, message }) {
83
+ if (code) {
84
+ warnings.push(warning(code, message));
85
+ }
79
86
  if (!json && !quiet) {
80
87
  switch (level) {
81
88
  case "warn":
@@ -252,6 +259,8 @@ const start = function (argv) {
252
259
  // the project, parsed once and given to each step. A build can write files
253
260
  // the manifest reads, such as art, so it's parsed again after one runs.
254
261
  let parsed = null;
262
+ // what --json lists under `warnings`: arc validate's, then the deploy's.
263
+ const warnings = [];
255
264
  let sw = new SpinnerWheel("Validating... %s");
256
265
  sw.start(json || quiet);
257
266
  return checkTimeout(timeout)
@@ -260,25 +269,29 @@ const start = function (argv) {
260
269
  parsed = proj;
261
270
  return validate({ project: parsed, root });
262
271
  })
263
- .then((issues) => {
264
- const count = issues.length;
272
+ .then((validated) => {
273
+ const count = validated.issues.length;
274
+ warnings.push(...validated.warnings);
265
275
  if (count) {
266
276
  throw new ArcError(
267
277
  cfgcode.VALIDATION_FAILED,
268
278
  phrase.issuesFound(count),
269
- { data: { issues } },
279
+ { data: { issues: validated.issues } },
270
280
  );
271
281
  }
272
282
  sw.stop(true);
283
+ warn(validated.warnings, { json, quiet });
273
284
  sw = new SpinnerWheel("Checking... %s");
274
285
  // git deploys need no login: anyone who can push to the repository can
275
286
  // release, and the Arcadible GitHub App publishes the release. Anyone
276
287
  // can wait for it too; the game's owner, logged in, waits among their
277
288
  // own releases, which also say when the repository isn't connected.
278
289
  if (git) {
279
- return whoamiIfAny({ handler: handler({ json, pb, quiet, sw }) });
290
+ return whoamiIfAny({
291
+ handler: handler({ json, pb, quiet, sw, warnings }),
292
+ });
280
293
  }
281
- return whoami({ handler: handler({ json, pb, quiet, sw }) });
294
+ return whoami({ handler: handler({ json, pb, quiet, sw, warnings }) });
282
295
  })
283
296
  .then((current) => {
284
297
  if (!current && !git) {
@@ -293,7 +306,7 @@ const start = function (argv) {
293
306
  predeploy({
294
307
  current,
295
308
  git,
296
- handler: handler({ json, pb, quiet, sw }),
309
+ handler: handler({ json, pb, quiet, sw, warnings }),
297
310
  project: parsed,
298
311
  root,
299
312
  }),
@@ -314,7 +327,23 @@ const start = function (argv) {
314
327
  .then(([current, artifact]) =>
315
328
  Promise.all([current, artifact, artifact ? project(root) : parsed]),
316
329
  )
317
- .then(([current, artifact, proj]) => {
330
+ .then(([current, artifact, proj]) =>
331
+ Promise.all([
332
+ current,
333
+ artifact,
334
+ proj,
335
+ proj === parsed ? null : validate({ project: proj, root }),
336
+ ]),
337
+ )
338
+ .then(([current, artifact, proj, validated]) => {
339
+ const count = validated?.issues.length ?? 0;
340
+ if (count) {
341
+ throw new ArcError(
342
+ cfgcode.VALIDATION_FAILED,
343
+ phrase.issuesAfterBuild(count),
344
+ { data: { issues: validated.issues } },
345
+ );
346
+ }
318
347
  parsed = proj;
319
348
  sw.stop(true);
320
349
  sw = new SpinnerWheel("Packing... %s");
@@ -341,7 +370,7 @@ const start = function (argv) {
341
370
  artifact,
342
371
  current,
343
372
  git,
344
- handler: handler({ json, pb, quiet, sw }),
373
+ handler: handler({ json, pb, quiet, sw, warnings }),
345
374
  project: parsed,
346
375
  root,
347
376
  }),
@@ -358,7 +387,7 @@ const start = function (argv) {
358
387
  const shown = { ...deployed };
359
388
  delete shown.game;
360
389
  delete shown.release;
361
- const result = git ? shown : { built, ...shown };
390
+ const result = { ...(git ? shown : { built, ...shown }), warnings };
362
391
  sw.stop(true);
363
392
  if (argv.wait) {
364
393
  return waitAndReport(current, deployed, result, {
@@ -378,7 +407,9 @@ const start = function (argv) {
378
407
  }
379
408
  return null;
380
409
  })
381
- .catch((ex) => abort("Deploy aborted.", ex, { json, pb, sw }));
410
+ .catch((ex) =>
411
+ abort("Deploy aborted.", ex, { data: { warnings }, json, pb, sw }),
412
+ );
382
413
  };
383
414
 
384
415
  // [function]
package/bin/arc-pack.js CHANGED
@@ -34,6 +34,7 @@ const findRc = rc.findRc;
34
34
  const print = output.print;
35
35
  const resolve = path.resolve;
36
36
  const SpinnerWheel = spinner.SpinnerWheel;
37
+ const warn = output.warn;
37
38
 
38
39
  // [declare]
39
40
 
@@ -59,6 +60,8 @@ const start = function (argv) {
59
60
  const root = resolve(process.cwd(), argv.cwd);
60
61
  // the project, parsed once for both steps.
61
62
  let parsed = null;
63
+ // what --json lists under `warnings`.
64
+ let warnings = [];
62
65
  let sw = new SpinnerWheel("Validating... %s");
63
66
  sw.start(json || quiet);
64
67
  return project(root)
@@ -66,16 +69,18 @@ const start = function (argv) {
66
69
  parsed = proj;
67
70
  return validate({ project: parsed, root });
68
71
  })
69
- .then((issues) => {
70
- const count = issues.length;
72
+ .then((validated) => {
73
+ const count = validated.issues.length;
74
+ warnings = validated.warnings;
71
75
  if (count) {
72
76
  throw new ArcError(
73
77
  cfgcode.VALIDATION_FAILED,
74
78
  phrase.issuesFound(count),
75
- { data: { issues } },
79
+ { data: { issues: validated.issues } },
76
80
  );
77
81
  }
78
82
  sw.stop(true);
83
+ warn(warnings, { json, quiet });
79
84
  sw = new SpinnerWheel("Packing... %s");
80
85
  sw.start(json || quiet);
81
86
  return pack({
@@ -87,7 +92,7 @@ const start = function (argv) {
87
92
  })
88
93
  .then((artifact) => {
89
94
  if (json) {
90
- print({ artifact });
95
+ print({ artifact, warnings });
91
96
  return;
92
97
  }
93
98
  if (!quiet) {
@@ -96,7 +101,9 @@ const start = function (argv) {
96
101
  print(artifact, { nl: true, toml: true });
97
102
  }
98
103
  })
99
- .catch((ex) => abort("Pack aborted.", ex, { json, sw }));
104
+ .catch((ex) =>
105
+ abort("Pack aborted.", ex, { data: { warnings }, json, sw }),
106
+ );
100
107
  };
101
108
 
102
109
  // [function]
@@ -28,6 +28,7 @@ const dereference = schema.dereference;
28
28
  const findRc = rc.findRc;
29
29
  const print = output.print;
30
30
  const resolve = path.resolve;
31
+ const schemaPrint = schema.print;
31
32
 
32
33
  // [declare]
33
34
 
@@ -48,24 +49,30 @@ const conf = {
48
49
  const start = function (argv) {
49
50
  const json = argv.json;
50
51
  return validate({ root: resolve(process.cwd(), argv.cwd) })
51
- .then((issues) => {
52
+ .then(({ issues, warnings }) => {
52
53
  const count = issues.length;
53
54
  if (json) {
54
- print({ issues });
55
- } else if (count) {
55
+ print({ issues, warnings });
56
+ } else {
56
57
  const [probs, refs] = dereference(issues);
57
58
  const probcount = probs.length;
58
59
  if (probcount) {
59
60
  log.hdo(`${probcount} ${probcount === 1 ? "issue" : "issues"}:`);
60
61
  log.p(probs.map((prob) => `✗ ${prob}`));
61
62
  }
63
+ const warncount = argv.quiet ? 0 : warnings.length;
64
+ if (warncount) {
65
+ log.hwo(`${warncount} ${warncount === 1 ? "warning" : "warnings"}:`);
66
+ log.p(warnings.map((each) => `⚠ ${schemaPrint(each)}`));
67
+ }
62
68
  const refcount = refs.length;
63
69
  if (refcount) {
64
70
  log.h(`${refcount} ${refcount === 1 ? "reference" : "references"}:`);
65
71
  log.p(refs);
66
72
  }
67
- } else if (!argv.quiet) {
68
- log.hs("No issues found.");
73
+ if (!count && !argv.quiet) {
74
+ log.hs("No issues found.");
75
+ }
69
76
  }
70
77
  process.exit(count ? 1 : 0);
71
78
  })
package/package.json CHANGED
@@ -75,5 +75,5 @@
75
75
  "scripts": {
76
76
  "test": "node --test \"test/**/*.test.js\""
77
77
  },
78
- "version": "0.1.42"
78
+ "version": "0.1.44"
79
79
  }
@@ -28,7 +28,7 @@ const POLL = {
28
28
  interval: time.s(15),
29
29
  maxDelay: time.s(5),
30
30
  timeoutError: () =>
31
- new ArcError(cfgcode.LOGIN_EXPIRED, phrase.loginExpired()),
31
+ new ArcError(cfgcode.LOGIN_CODE_EXPIRED, phrase.loginCodeExpired()),
32
32
  };
33
33
 
34
34
  // [function]
@@ -73,7 +73,10 @@ const pollFlow = function (value, onLoginPoll) {
73
73
  case "failed":
74
74
  throw new ArcError(cfgcode.LOGIN_FAILED, phrase.loginFailed());
75
75
  default:
76
- throw new ArcError(cfgcode.LOGIN_EXPIRED, phrase.loginExpired());
76
+ throw new ArcError(
77
+ cfgcode.LOGIN_CODE_EXPIRED,
78
+ phrase.loginCodeExpired(),
79
+ );
77
80
  }
78
81
  });
79
82
  };
@@ -69,15 +69,26 @@ const verifyTotal = function (zip) {
69
69
 
70
70
  // [function]
71
71
 
72
- const pack = function (name, game, { inputPath, mediaPath, outputPath }) {
72
+ // `deployDir` when the input is deploy_dir, rather than a folder --input
73
+ // named: a missing deploy_dir means the build hasn't written it.
74
+ const pack = function (
75
+ name,
76
+ game,
77
+ { deployDir, inputPath, mediaPath, outputPath },
78
+ ) {
73
79
  const zip = new Zip(name);
74
80
  return pathExists(inputPath)
75
81
  .then((existsInput) => {
76
82
  if (!existsInput) {
77
- throw new ArcError(
78
- cfgcode.PATH_NOT_FOUND,
79
- phrase.unableToFind(inputPath),
80
- );
83
+ throw deployDir
84
+ ? new ArcError(
85
+ cfgcode.DEPLOY_DIR_MISSING,
86
+ phrase.deployDirMissing(inputPath),
87
+ )
88
+ : new ArcError(
89
+ cfgcode.PATH_NOT_FOUND,
90
+ phrase.unableToFind(inputPath),
91
+ );
81
92
  }
82
93
  zip.addDir(inputPath);
83
94
  return zip;
@@ -105,8 +116,11 @@ const main = function ({ input, output, ...conf }) {
105
116
  return given(conf).then(({ game, workflow }) => {
106
117
  const name = game.handle ?? game.id;
107
118
  const file = output ?? `${OUTPUT}/${name}-v${game.version}.zip`;
119
+ const deployDir = resolve(root, workflow.deploy_dir);
120
+ const inputPath = resolve(root, input ?? workflow.deploy_dir);
108
121
  return pack(parse(file).name, game, {
109
- inputPath: resolve(root, input ?? workflow.deploy_dir),
122
+ deployDir: inputPath === deployDir,
123
+ inputPath,
110
124
  mediaPath: resolve(root, MEDIA),
111
125
  outputPath: resolve(root, file),
112
126
  });
@@ -77,11 +77,28 @@ const ensureClean = function (root) {
77
77
 
78
78
  // [function]
79
79
 
80
+ // fails with git_remote_missing when there's no origin, and with
81
+ // git_remote_unreachable when it's there but can't be reached.
80
82
  const ensureRemote = function (root) {
81
- return git(root, ["ls-remote", "origin"]).then(
82
- () => null,
83
- failWith(cfgcode.GIT_REMOTE_UNAVAILABLE, phrase.gitRemoteMissing()),
84
- );
83
+ return git(root, ["remote", "get-url", "origin"])
84
+ .then(
85
+ () => null,
86
+ failWith(cfgcode.GIT_REMOTE_MISSING, phrase.gitRemoteMissing()),
87
+ )
88
+ .then(() => git(root, ["ls-remote", "origin"]))
89
+ .then(
90
+ () => null,
91
+ // git_remote_missing passes through.
92
+ (err) => {
93
+ if (err instanceof ArcError) {
94
+ throw err;
95
+ }
96
+ failWith(
97
+ cfgcode.GIT_REMOTE_UNREACHABLE,
98
+ phrase.gitRemoteUnreachable(),
99
+ )(err);
100
+ },
101
+ );
85
102
  };
86
103
 
87
104
  // [function]
@@ -168,7 +185,7 @@ const hasTagLocal = function (root, tag) {
168
185
  const hasTagRemote = function (root, tag) {
169
186
  return git(root, ["ls-remote", "--tags", "origin", `refs/tags/${tag}`]).then(
170
187
  (stdout) => Boolean(stdout.trim()),
171
- failWith(cfgcode.GIT_REMOTE_UNAVAILABLE, phrase.gitRemoteMissing()),
188
+ failWith(cfgcode.GIT_REMOTE_UNREACHABLE, phrase.gitRemoteUnreachable()),
172
189
  );
173
190
  };
174
191
 
@@ -57,6 +57,7 @@ const ensurePublishable = function ({ handle, id, version }, handler) {
57
57
  if (game === null || build === null) {
58
58
  if (handler.onLogInfo) {
59
59
  handler.onLogInfo({
60
+ code: cfgcode.WARNING.VERSION_UNCHECKED,
60
61
  level: "warn",
61
62
  message: phrase.versionUnchecked(version),
62
63
  });
@@ -8,6 +8,7 @@ const readme = require("../../schema/readme");
8
8
  const readmeFile = require("../../parser/readme-file");
9
9
  const schema = require("../../util/schema");
10
10
  const sections = require("../../parser/sections");
11
+ const unknown = require("./unknown");
11
12
  const workflow = require("../../schema/workflow");
12
13
 
13
14
  // [use]
@@ -49,10 +50,14 @@ const validate = function (proj, readmeStructure, { root }) {
49
50
 
50
51
  // [function]
51
52
 
53
+ // the project's issues, which fail it, and its warnings, which don't.
52
54
  const main = function (conf) {
53
55
  const root = conf.root;
54
- return Promise.all([given(conf), structure(root)]).then(
55
- ([proj, readmeStructure]) => validate(proj, readmeStructure, { root }),
56
+ return Promise.all([given(conf), structure(root), unknown(root)]).then(
57
+ ([proj, readmeStructure, warnings]) => ({
58
+ issues: validate(proj, readmeStructure, { root }),
59
+ warnings: warnings.sort(schema.compare),
60
+ }),
56
61
  );
57
62
  };
58
63
 
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ // [import]
4
+
5
+ const cfgcode = require("../../config/code");
6
+ const game = require("../../schema/game");
7
+ const manifest = require("../../parser/manifest");
8
+ const strs = require("../../util/strs");
9
+ const workflow = require("../../schema/workflow");
10
+
11
+ // [use]
12
+
13
+ const closest = strs.closest;
14
+ const WARNING = cfgcode.WARNING;
15
+
16
+ // [function]
17
+
18
+ // the fields each table takes, from its schema.
19
+ const known = function (root) {
20
+ return {
21
+ game: Object.keys(game({}, { root }).shape),
22
+ workflow: Object.keys(workflow({}, { root }).shape),
23
+ };
24
+ };
25
+
26
+ // [function]
27
+
28
+ // a warning, in an issue's shape: Arcadible ignores what it doesn't know, so
29
+ // it's reported without failing, with the name it might have meant.
30
+ const warning = function (path, message, near) {
31
+ return {
32
+ code: WARNING.FIELD_UNKNOWN,
33
+ details: near ? [`Did you mean: ${near}?`] : [],
34
+ link: null,
35
+ message,
36
+ path,
37
+ };
38
+ };
39
+
40
+ // [function]
41
+
42
+ // a value outside any table, such as a `handle` above `[game]`: the field
43
+ // it is, in the table that takes it, if any.
44
+ const untabled = function (fields, name) {
45
+ const table = Object.keys(fields).find((each) => fields[each].includes(name));
46
+ return warning(name, "Unknown field.", table ? `${table}.${name}` : null);
47
+ };
48
+
49
+ // [function]
50
+
51
+ const isTable = function (value) {
52
+ return value !== null && typeof value === "object" && !Array.isArray(value);
53
+ };
54
+
55
+ // [function]
56
+
57
+ // the tables and fields in arcadible.toml that Arcadible doesn't know, such
58
+ // as a misspelled `genre`, as warnings.
59
+ const main = function (root) {
60
+ const fields = known(root);
61
+ const tables = Object.keys(fields);
62
+ return manifest(root).then((conf) =>
63
+ Object.entries(conf).flatMap(([table, values]) => {
64
+ if (!isTable(values)) {
65
+ return Object.hasOwn(fields, table) ? [] : [untabled(fields, table)];
66
+ }
67
+ if (!Object.hasOwn(fields, table)) {
68
+ return [warning(table, "Unknown table.", closest(table, tables))];
69
+ }
70
+ return Object.keys(values)
71
+ .filter((field) => !fields[table].includes(field))
72
+ .map((field) =>
73
+ warning(
74
+ `${table}.${field}`,
75
+ "Unknown field.",
76
+ closest(field, fields[table]),
77
+ ),
78
+ );
79
+ }),
80
+ );
81
+ };
82
+
83
+ // [export]
84
+
85
+ module.exports = main;
@@ -8,24 +8,26 @@
8
8
 
9
9
  // the CLI's own codes.
10
10
  const CLI = {
11
- API_ERROR: "api_error",
11
+ API_REFUSED: "api_refused",
12
+ API_UNREACHABLE: "api_unreachable",
12
13
  ARGUMENT_INVALID: "argument_invalid",
13
14
  BUILD_FAILED: "build_failed",
15
+ DEPLOY_DIR_MISSING: "deploy_dir_missing",
14
16
  GAME_EXISTS: "game_exists",
15
17
  GAME_ID_MISSING: "game_id_missing",
16
18
  GAME_NOT_DEPLOYED: "game_not_deployed",
17
19
  GAME_NOT_FOUND: "game_not_found",
18
- GIT_REMOTE_UNAVAILABLE: "git_remote_unavailable",
20
+ GIT_REMOTE_MISSING: "git_remote_missing",
21
+ GIT_REMOTE_UNREACHABLE: "git_remote_unreachable",
19
22
  GIT_REPO_MISSING: "git_repo_missing",
20
23
  GIT_TAG_CREATE_FAILED: "git_tag_create_failed",
21
24
  GIT_TAG_EXISTS: "git_tag_exists",
22
25
  GIT_TAG_PUSH_FAILED: "git_tag_push_failed",
23
26
  GIT_UNCOMMITTED: "git_uncommitted",
24
27
  LOCKFILE_MISSING: "lockfile_missing",
25
- LOGIN_EXPIRED: "login_expired",
28
+ LOGIN_CODE_EXPIRED: "login_code_expired",
26
29
  LOGIN_FAILED: "login_failed",
27
30
  MANIFEST_MISSING: "manifest_missing",
28
- NETWORK_ERROR: "network_error",
29
31
  NOT_LOGGED_IN: "not_logged_in",
30
32
  PACKAGE_FILE_TOO_LARGE: "package_file_too_large",
31
33
  PACKAGE_INDEX_MISSING: "package_index_missing",
@@ -48,24 +50,26 @@ const CLI = {
48
50
  // thing wherever it appears: GAME_UNAVAILABLE, INTERNAL, REPO_NOT_CONNECTED,
49
51
  // and VERSION_EXISTS are the CLI's too.
50
52
  const RELEASE = {
51
- APP_NOT_INSTALLED: "app_not_installed",
52
- EXPIRED: "expired",
53
+ BUILD_RATE_LIMITED: "build_rate_limited",
53
54
  GAME_LIMIT_REACHED: "game_limit_reached",
54
55
  GAME_MISMATCH: "game_mismatch",
55
56
  GAME_UNAVAILABLE: "game_unavailable",
57
+ GITHUB_APP_NOT_INSTALLED: "github_app_not_installed",
56
58
  INTERNAL: "internal",
57
59
  MANIFEST_INVALID: "manifest_invalid",
58
- RATE_LIMITED: "rate_limited",
60
+ RELEASE_EXPIRED: "release_expired",
59
61
  REPO_NOT_CONNECTED: "repo_not_connected",
60
62
  VERSION_EXISTS: "version_exists",
63
+ VERSION_MISMATCH: "version_mismatch",
61
64
  ZIP_INVALID: "zip_invalid",
65
+ ZIP_MISSING: "zip_missing",
62
66
  ZIP_TOO_LARGE: "zip_too_large",
63
67
  };
64
68
  // the Arcadible check's own codes on GitHub, for a `git` release that fails
65
- // before there's a release (the queue publisher Lambda). `arc deploy git`
69
+ // before there's a release (the queue publisher and subscriber Lambdas). `arc deploy git`
66
70
  // checks for workflow_missing before it pushes.
67
71
  const GITHUB = {
68
- TAG_INVALID: "tag_invalid",
72
+ GIT_TAG_INVALID: "git_tag_invalid",
69
73
  WORKFLOW_MISSING: "workflow_missing",
70
74
  };
71
75
  // an `arc validate` issue's codes, named for the rule it breaks; the field
@@ -92,6 +96,14 @@ const ISSUE = {
92
96
  UUID_INVALID: "uuid_invalid",
93
97
  VERSION_INVALID: "version_invalid",
94
98
  };
99
+ // a warning's codes: what's reported without failing, in an issue's shape.
100
+ // they follow the same rules, and share the Errors page, so no warning code
101
+ // is also an error or issue code.
102
+ const WARNING = {
103
+ DEPLOY_DIR_NOT_BUILT: "deploy_dir_not_built",
104
+ FIELD_UNKNOWN: "field_unknown",
105
+ VERSION_UNCHECKED: "version_unchecked",
106
+ };
95
107
  // every error code, for checking one exists.
96
108
  const ALL = new Set([
97
109
  ...Object.values(CLI),
@@ -100,6 +112,8 @@ const ALL = new Set([
100
112
  ]);
101
113
  // every issue code.
102
114
  const ISSUES = new Set(Object.values(ISSUE));
115
+ // every warning code.
116
+ const WARNINGS = new Set(Object.values(WARNING));
103
117
 
104
118
  // [export]
105
119
 
@@ -112,4 +126,6 @@ module.exports = {
112
126
  ISSUE,
113
127
  ISSUES,
114
128
  RELEASE,
129
+ WARNING,
130
+ WARNINGS,
115
131
  };
package/src/phrase.js CHANGED
@@ -101,6 +101,15 @@ const cmdValidate = function () {
101
101
 
102
102
  // [function]
103
103
 
104
+ const deployDirMissing = function (dir) {
105
+ return blocks(
106
+ `Unable to find deploy_dir: ${dir}`,
107
+ "Run `arc build`, or set deploy_dir to the folder your build writes.",
108
+ );
109
+ };
110
+
111
+ // [function]
112
+
104
113
  const docAttrInvalid = function (tag, attr, value) {
105
114
  return `Value '${value}' is not valid for the '${attr}' attribute on the '${tag}' tag.`;
106
115
  };
@@ -207,8 +216,17 @@ const gitChangesPresent = function () {
207
216
 
208
217
  const gitRemoteMissing = function () {
209
218
  return blocks(
210
- "Unable to access Git remote 'origin'.",
211
- "Check that it exists and that you can access it.",
219
+ "No Git remote 'origin'.",
220
+ "Add the game's GitHub repository as origin.",
221
+ );
222
+ };
223
+
224
+ // [function]
225
+
226
+ const gitRemoteUnreachable = function () {
227
+ return blocks(
228
+ "Unable to reach Git remote 'origin'.",
229
+ "Check your connection, and that you can access it.",
212
230
  );
213
231
  };
214
232
 
@@ -216,7 +234,7 @@ const gitRemoteMissing = function () {
216
234
 
217
235
  const noManifest = function (file, dir, found) {
218
236
  return blocks(
219
- dir === "." ? `No ${file} in this directory.` : `No ${file} in ${dir}`,
237
+ dir === "." ? `No ${file} in this directory.` : `No ${file} in ${dir}.`,
220
238
  found
221
239
  ? `The project is in \`${found}\`: run arc there, or pass \`-C ${found}\`.`
222
240
  : "Run arc in the project's directory, or pass -C, or start a project with `arc init`.",
@@ -337,6 +355,17 @@ const timeoutInvalid = function () {
337
355
 
338
356
  // [function]
339
357
 
358
+ // issues in what the build wrote, such as its art, once arc deploy has
359
+ // parsed the project again.
360
+ const issuesAfterBuild = function (count) {
361
+ return blocks(
362
+ `${count} ${count === 1 ? "issue" : "issues"} found after the build.`,
363
+ `The build writes files the manifest reads. Run ${cmdValidate()} for details.`,
364
+ );
365
+ };
366
+
367
+ // [function]
368
+
340
369
  const issuesFound = function (count) {
341
370
  return blocks(
342
371
  `${count} ${count === 1 ? "issue" : "issues"} found.`,
@@ -346,8 +375,8 @@ const issuesFound = function (count) {
346
375
 
347
376
  // [function]
348
377
 
349
- const loginExpired = function () {
350
- return "Login request expired.";
378
+ const loginCodeExpired = function () {
379
+ return "Login code expired.";
351
380
  };
352
381
 
353
382
  // [function]
@@ -458,10 +487,9 @@ const packageUncompressedTooLarge = function (size) {
458
487
  // and where to see it.
459
488
  // `studio` is the game's builds in Studio, for its owner; anyone else can't
460
489
  // open it.
461
- const releaseFailed = function ({ message, reason }, studio) {
490
+ const releaseFailed = function ({ message }, studio) {
462
491
  return blocks(
463
492
  (message ?? "").split(/(?<=\.)\s+/u).join("\n"),
464
- `Reason: ${reason}.`,
465
493
  ...(studio ? [[`${TITLE} Studio:`, studio]] : []),
466
494
  );
467
495
  };
@@ -756,6 +784,7 @@ module.exports = {
756
784
  buildSubmitted,
757
785
  cmdInit,
758
786
  cmdInspect,
787
+ deployDirMissing,
759
788
  docAttrInvalid,
760
789
  docAttrUnallowed,
761
790
  docCommentUnallowed,
@@ -772,6 +801,7 @@ module.exports = {
772
801
  gameUnavailable,
773
802
  gitChangesPresent,
774
803
  gitRemoteMissing,
804
+ gitRemoteUnreachable,
775
805
  gitRepoMissing,
776
806
  gitTagCreateFailed,
777
807
  gitTagPushFailed,
@@ -786,8 +816,9 @@ module.exports = {
786
816
  imageUnsupported,
787
817
  imageWidthMax,
788
818
  imageWidthMin,
819
+ issuesAfterBuild,
789
820
  issuesFound,
790
- loginExpired,
821
+ loginCodeExpired,
791
822
  loginFailed,
792
823
  noGameId,
793
824
  noLockFile,
@@ -177,7 +177,7 @@ const zSetBasefile = function (field, schema, { max, min }) {
177
177
  const zString = function (field, char, { max, min }) {
178
178
  return z
179
179
  .string()
180
- .min(min, atLeast(field, min, "character"))
180
+ .min(min, { ...atLeast(field, min, "character"), abort: min === 1 })
181
181
  .max(max, message(field, [`Must not exceed: ${max} characters`]))
182
182
  .superRefine((str, ctx) => {
183
183
  const invalid = invalidChars(str, char);
@@ -430,15 +430,29 @@ const zGenres = function () {
430
430
 
431
431
  // [function]
432
432
 
433
+ // each of a handle's rules on its own, so a handle lists every one it breaks:
434
+ // its length, its characters, and its hyphens. The database's
435
+ // check (db/check/game.sql) is the same rules in one regex.
433
436
  const zHandle = function () {
434
437
  const field = "handle";
435
438
  return z.optional(
436
- z
437
- .string()
438
- .min(1, { ...atLeast(field, 1), abort: true })
439
- .regex(
440
- /^[a-z0-9](?:(?![-]{2})[a-z0-9-]){1,22}[a-z0-9]$/u,
441
- message(field, ["Contains invalid characters."]),
439
+ zString(field, /[a-z0-9-]/u, { max: 24, min: 1 })
440
+ .min(3, atLeast(field, 3, "characters"))
441
+ .refine(
442
+ (str) => !str.startsWith("-") && !str.endsWith("-"),
443
+ message(
444
+ field,
445
+ ["Must not start or end with a hyphen."],
446
+ ISSUE.CHARACTERS_INVALID,
447
+ ),
448
+ )
449
+ .refine(
450
+ (str) => !str.includes("--"),
451
+ message(
452
+ field,
453
+ ["Must not contain consecutive hyphens."],
454
+ ISSUE.CHARACTERS_INVALID,
455
+ ),
442
456
  ),
443
457
  );
444
458
  };
package/src/util/error.js CHANGED
@@ -32,14 +32,15 @@ const ArcError = class extends Error {
32
32
 
33
33
  // [function]
34
34
 
35
- // the code a failure reports: its own, api_error for a request the API
36
- // refused, or internal for anything else, which is a bug in the CLI.
35
+ // the code a failure reports: its own, api_refused for a request the API
36
+ // refused, or internal for a failure on Arcadible's side (a 5xx) and for
37
+ // anything else, which is a bug in the CLI.
37
38
  const codeOf = function (ex) {
38
39
  if (ex instanceof ArcError) {
39
40
  return ex.code;
40
41
  }
41
- if (ex instanceof HttpError) {
42
- return cfgcode.API_ERROR;
42
+ if (ex instanceof HttpError && !(ex.status >= 500)) {
43
+ return cfgcode.API_REFUSED;
43
44
  }
44
45
  return cfgcode.INTERNAL;
45
46
  };
package/src/util/log.js CHANGED
@@ -82,6 +82,14 @@ const hdo = function (message) {
82
82
 
83
83
  // [function]
84
84
 
85
+ // a warning heading on stdout, for when the warnings are part of the result,
86
+ // as with arc validate's.
87
+ const hwo = function (message) {
88
+ log(console.log, message, { bold: true, color: "yellow" });
89
+ };
90
+
91
+ // [function]
92
+
85
93
  const pw = function (message) {
86
94
  log(console.warn, message, { color: "yellow" });
87
95
  };
@@ -122,6 +130,7 @@ module.exports = {
122
130
  hd,
123
131
  hdo,
124
132
  hs,
133
+ hwo,
125
134
  nlhd,
126
135
  nlp,
127
136
  nlpd,
@@ -11,6 +11,7 @@ const error = require("./error");
11
11
  const log = require("./log");
12
12
  const objs = require("./objs");
13
13
  const phrase = require("../phrase");
14
+ const schema = require("./schema");
14
15
  const TOML = require("./toml");
15
16
 
16
17
  // [use]
@@ -65,7 +66,7 @@ const print = function (data, { key, nl, toml } = {}) {
65
66
  // release that failed. `nl` puts a blank line before the heading, after
66
67
  // output the command has already printed, such as arc login's link.
67
68
  // `code` is a failed release's reason, which the API may add to, so it isn't
68
- // checked, and its message already says it.
69
+ // checked.
69
70
  const abort = function (
70
71
  heading,
71
72
  ex,
@@ -73,12 +74,15 @@ const abort = function (
73
74
  ) {
74
75
  const message = ex.message;
75
76
  const status = ex.status;
77
+ const shown = code ?? codeOf(ex);
78
+ const link = `${cfgurl.ERRORS}#${shown}`;
76
79
  if (json) {
77
80
  print({
78
81
  ...ex.data,
79
82
  ...data,
80
83
  error: {
81
- code: code ?? codeOf(ex),
84
+ code: shown,
85
+ link,
82
86
  message,
83
87
  ...(status ? { status } : {}),
84
88
  },
@@ -96,16 +100,38 @@ const abort = function (
96
100
  log.hd(heading);
97
101
  }
98
102
  log.pd(message);
99
- if (!code) {
100
- log.nlpd([
101
- phrase.errorCode(codeOf(ex)),
102
- chalk.cyan.underline(`${cfgurl.ERRORS}#${codeOf(ex)}`),
103
- ]);
104
- }
103
+ log.nlpd([phrase.errorCode(shown), chalk.cyan.underline(link)]);
105
104
  }
106
105
  process.exit(1);
107
106
  };
108
107
 
108
+ // [function]
109
+
110
+ // arc validate's warnings, such as an unknown field, on stderr in yellow,
111
+ // before a command that validates goes on. Left out with `--json` and
112
+ // `--quiet`; arc validate --json lists them.
113
+ const warn = function (warnings, { json, quiet } = {}) {
114
+ if (json || quiet) {
115
+ return;
116
+ }
117
+ warnings.forEach((each) => log.pw(`⚠ ${schema.print(each)}`));
118
+ };
119
+
120
+ // [function]
121
+
122
+ // a warning of the command's own, in an issue's shape, as --json lists it
123
+ // under `warnings` beside arc validate's: its code, and the link to it on
124
+ // the Errors page. `path` is the field it's about, if any.
125
+ const warning = function (code, message, path = null) {
126
+ return {
127
+ code,
128
+ details: [],
129
+ link: `${cfgurl.ERRORS}#${code}`,
130
+ message,
131
+ path,
132
+ };
133
+ };
134
+
109
135
  // [export]
110
136
 
111
- module.exports = { abort, print, printJson, printToml };
137
+ module.exports = { abort, print, printJson, printToml, warn, warning };
@@ -53,7 +53,7 @@ const parse = function (resp) {
53
53
 
54
54
  // sends a request to a full URL. `headers` are added to the client's own, and
55
55
  // a form is sent as JSON. A request that gets no response, such as when
56
- // offline, rejects as network_error.
56
+ // offline, rejects as api_unreachable.
57
57
  const send = function (method, url, form, headers = {}) {
58
58
  const all = {
59
59
  [`X-${CLIENT}-Client-Id`]: ID,
@@ -66,7 +66,7 @@ const send = function (method, url, form, headers = {}) {
66
66
  headers: all,
67
67
  method,
68
68
  }).then(parse, (ex) => {
69
- throw new ArcError(cfgcode.NETWORK_ERROR, phrase.unableToReach(), {
69
+ throw new ArcError(cfgcode.API_UNREACHABLE, phrase.unableToReach(), {
70
70
  cause: ex,
71
71
  });
72
72
  });
package/src/util/s3.js CHANGED
@@ -32,7 +32,7 @@ const requestOpts = function (size) {
32
32
  // [function]
33
33
 
34
34
  // puts the file to the upload link, and resolves once S3 accepts it. A
35
- // request that gets no response rejects as network_error; a file that can't
35
+ // request that gets no response rejects as api_unreachable; a file that can't
36
36
  // be read rejects with its own error.
37
37
  const upload = function (url, { path, size }, onProgress) {
38
38
  return new Promise((resolve, reject) => {
@@ -49,7 +49,7 @@ const upload = function (url, { path, size }, onProgress) {
49
49
  req.on("error", (ex) => {
50
50
  reject(
51
51
  readError ??
52
- new ArcError(cfgcode.NETWORK_ERROR, phrase.unableToReach(), {
52
+ new ArcError(cfgcode.API_UNREACHABLE, phrase.unableToReach(), {
53
53
  cause: ex,
54
54
  }),
55
55
  );
package/src/util/strs.js CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  // [function]
4
4
 
5
+ // the number of single-character edits between two strings (Levenshtein).
6
+ const distance = function (a, b) {
7
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
8
+ for (let i = 1; i <= a.length; i += 1) {
9
+ const next = [i];
10
+ for (let j = 1; j <= b.length; j += 1) {
11
+ next[j] = Math.min(
12
+ row[j] + 1,
13
+ next[j - 1] + 1,
14
+ row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
15
+ );
16
+ }
17
+ row = next;
18
+ }
19
+ return row[b.length];
20
+ };
21
+
22
+ // [function]
23
+
24
+ // the candidate nearest `str`, for "Did you mean", or null when none is
25
+ // within two edits.
26
+ const closest = function (str, candidates) {
27
+ let best = null;
28
+ let min = 3;
29
+ for (const candidate of candidates) {
30
+ const d = distance(str, candidate);
31
+ if (d < min) {
32
+ best = candidate;
33
+ min = d;
34
+ }
35
+ }
36
+ return best;
37
+ };
38
+
39
+ // [function]
40
+
5
41
  const indentLines = function (strs, spaces = 2) {
6
42
  const pad = " ".repeat(spaces);
7
43
  return strs
@@ -28,4 +64,10 @@ const trimWhitespace = function (str) {
28
64
 
29
65
  // [export]
30
66
 
31
- module.exports = { indentLines, stripLeadingInvisible, trimWhitespace };
67
+ module.exports = {
68
+ closest,
69
+ distance,
70
+ indentLines,
71
+ stripLeadingInvisible,
72
+ trimWhitespace,
73
+ };