@ait-co/console-cli 0.1.15 → 0.1.17

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
@@ -99,3 +99,15 @@ Every command accepts `--json`. When set:
99
99
  ## Status
100
100
 
101
101
  `login`, `logout`, `whoami`, and `upgrade` are implemented end-to-end — `login` drives a real browser over CDP and `whoami` reads the live console member API. `deploy`, `logs`, `status` are next — see [TODO.md](./TODO.md). See the [organization landing page](https://apps-in-toss-community.github.io/) for the full roadmap.
102
+
103
+ ## Pre-commit hook
104
+
105
+ Optional but recommended. After cloning, activate the standard pre-commit hook (runs `biome check` on staged files):
106
+
107
+ ```sh
108
+ git config core.hooksPath .githooks
109
+ ```
110
+
111
+ This is a developer convenience for fast feedback before push. CI runs the same checks as the enforcement layer, so contributors who don't activate the hook will still see lint failures in their PR.
112
+
113
+ 선택 사항이지만 권장합니다. clone 후 표준 pre-commit hook을 활성화하면 staged 파일에 `biome check`가 자동으로 돕니다 (push 전에 빠른 피드백). 활성화하지 않아도 동일한 검사가 CI에서 실행되므로 PR 단계에서 lint 실패를 볼 수 있습니다.
package/dist/cli.mjs CHANGED
@@ -7,7 +7,8 @@ import { unzipSync } from "fflate";
7
7
  import { parse } from "yaml";
8
8
  import { imageSize } from "image-size";
9
9
  import { spawn } from "node:child_process";
10
- import { constants } from "node:fs";
10
+ import { constants, createReadStream } from "node:fs";
11
+ import { createHash } from "node:crypto";
11
12
  //#region src/api/http.ts
12
13
  var TossApiError = class extends Error {
13
14
  constructor(status, errorCode, reason, errorType) {
@@ -718,7 +719,8 @@ const ExitCode = {
718
719
  LoginCookieCaptureFailed: 16,
719
720
  ApiError: 17,
720
721
  UpgradeUnavailable: 20,
721
- UpgradeAlreadyLatest: 21
722
+ UpgradeAlreadyLatest: 21,
723
+ UpgradeChecksumFailed: 22
722
724
  };
723
725
  //#endregion
724
726
  //#region src/flush.ts
@@ -1017,13 +1019,37 @@ var AitBundleError = class extends Error {
1017
1019
  this.reason = args.reason;
1018
1020
  }
1019
1021
  };
1022
+ const AIT_MAGIC = new Uint8Array([
1023
+ 65,
1024
+ 73,
1025
+ 84,
1026
+ 66,
1027
+ 85,
1028
+ 78,
1029
+ 68,
1030
+ 76
1031
+ ]);
1032
+ const ZIP_MAGIC = new Uint8Array([
1033
+ 80,
1034
+ 75,
1035
+ 3,
1036
+ 4
1037
+ ]);
1038
+ function startsWith(bytes, prefix) {
1039
+ if (bytes.length < prefix.length) return false;
1040
+ for (let i = 0; i < prefix.length; i++) if (bytes[i] !== prefix[i]) return false;
1041
+ return true;
1042
+ }
1043
+ function detectBundleFormat(bytes) {
1044
+ if (startsWith(bytes, AIT_MAGIC)) return "ait";
1045
+ if (startsWith(bytes, ZIP_MAGIC)) return "zip";
1046
+ return "unknown";
1047
+ }
1020
1048
  /**
1021
- * Read the `.ait` at `path`, extract `app.json`, and pull out
1022
- * `_metadata.deploymentId`. Returns both the id and the raw bytes so the
1023
- * caller can forward them to the S3 upload without re-reading the file.
1024
- *
1025
- * Errors are all surfaced as `AitBundleError` with a structured `reason`
1026
- * so the command layer can render a typed `--json` failure.
1049
+ * Read the `.ait` at `path` and pull out `deploymentId`, auto-detecting
1050
+ * whether the file is the modern AIT header format or a legacy plain
1051
+ * zip. Returns the raw bytes so the caller can forward them to S3
1052
+ * without re-reading the file.
1027
1053
  */
1028
1054
  async function readAitBundle(path) {
1029
1055
  let buf;
@@ -1037,16 +1063,109 @@ async function readAitBundle(path) {
1037
1063
  });
1038
1064
  }
1039
1065
  const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
1066
+ const { deploymentId, format } = deploymentIdFromBundleBytes(bytes, path);
1040
1067
  return {
1041
- deploymentId: deploymentIdFromBundleBytes(bytes, path),
1042
- bytes
1068
+ deploymentId,
1069
+ bytes,
1070
+ format
1043
1071
  };
1044
1072
  }
1045
1073
  /**
1046
- * Pure helper split out so tests can feed raw zip bytes without a tmp
1047
- * file. Throws `AitBundleError` on any parse failure.
1074
+ * Pure helper split out so tests can feed raw bytes without a tmp file.
1075
+ * Throws `AitBundleError` on any parse failure. Returns the detected
1076
+ * format so callers that want to log it can.
1048
1077
  */
1049
1078
  function deploymentIdFromBundleBytes(bytes, pathForError) {
1079
+ const format = detectBundleFormat(bytes);
1080
+ if (format === "ait") return {
1081
+ deploymentId: deploymentIdFromAitHeader(bytes, pathForError),
1082
+ format
1083
+ };
1084
+ if (format === "zip") return {
1085
+ deploymentId: deploymentIdFromLegacyZip(bytes, pathForError),
1086
+ format
1087
+ };
1088
+ throw new AitBundleError({
1089
+ path: pathForError,
1090
+ reason: "unrecognized-format",
1091
+ message: "bundle does not start with AITBUNDL or PK magic bytes — not a valid .ait or legacy zip bundle"
1092
+ });
1093
+ }
1094
+ const AIT_MAGIC_SIZE = 8;
1095
+ const AIT_VERSION_SIZE = 4;
1096
+ const AIT_HEADER_SIZE = AIT_MAGIC_SIZE + AIT_VERSION_SIZE + 8;
1097
+ function deploymentIdFromAitHeader(bytes, pathForError) {
1098
+ if (bytes.length < AIT_HEADER_SIZE) throw new AitBundleError({
1099
+ path: pathForError,
1100
+ reason: "invalid-ait",
1101
+ message: "buffer too small to be a valid AIT file"
1102
+ });
1103
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1104
+ const bundleLen = Number(view.getBigUint64(AIT_MAGIC_SIZE + AIT_VERSION_SIZE, false));
1105
+ if (!Number.isFinite(bundleLen) || bundleLen <= 0) throw new AitBundleError({
1106
+ path: pathForError,
1107
+ reason: "invalid-ait",
1108
+ message: `AIT bundle length is invalid (${bundleLen})`
1109
+ });
1110
+ const bundleStart = AIT_HEADER_SIZE;
1111
+ const bundleEnd = bundleStart + bundleLen;
1112
+ if (bytes.length < bundleEnd) throw new AitBundleError({
1113
+ path: pathForError,
1114
+ reason: "invalid-ait",
1115
+ message: "unexpected end of buffer reading AIT bundle protobuf"
1116
+ });
1117
+ const deploymentId = readProtobufStringFields(bytes.subarray(bundleStart, bundleEnd), [2, 3]).get(2);
1118
+ if (typeof deploymentId !== "string" || deploymentId === "") throw new AitBundleError({
1119
+ path: pathForError,
1120
+ reason: "missing-deployment-id",
1121
+ message: "AIT bundle protobuf is missing deploymentId (field 2)"
1122
+ });
1123
+ return deploymentId;
1124
+ }
1125
+ function readProtobufStringFields(bytes, wantedFieldNumbers) {
1126
+ const wanted = new Set(wantedFieldNumbers);
1127
+ const out = /* @__PURE__ */ new Map();
1128
+ const decoder = new TextDecoder("utf-8", { fatal: false });
1129
+ let offset = 0;
1130
+ while (offset < bytes.length) {
1131
+ const { value: tag, next } = readVarint(bytes, offset);
1132
+ offset = next;
1133
+ const fieldNumber = Number(tag >> 3n);
1134
+ const wireType = Number(tag & 7n);
1135
+ if (wireType === 0) offset = readVarint(bytes, offset).next;
1136
+ else if (wireType === 1) offset += 8;
1137
+ else if (wireType === 2) {
1138
+ const { value: len, next: afterLen } = readVarint(bytes, offset);
1139
+ offset = afterLen;
1140
+ const payloadEnd = offset + Number(len);
1141
+ if (payloadEnd > bytes.length) break;
1142
+ if (wanted.has(fieldNumber)) out.set(fieldNumber, decoder.decode(bytes.subarray(offset, payloadEnd)));
1143
+ offset = payloadEnd;
1144
+ } else if (wireType === 5) offset += 4;
1145
+ else break;
1146
+ }
1147
+ return out;
1148
+ }
1149
+ function readVarint(bytes, start) {
1150
+ let value = 0n;
1151
+ let shift = 0n;
1152
+ let i = start;
1153
+ for (let n = 0; n < 10 && i < bytes.length; n++, i++) {
1154
+ const byte = bytes[i];
1155
+ if (byte === void 0) break;
1156
+ value |= BigInt(byte & 127) << shift;
1157
+ if ((byte & 128) === 0) return {
1158
+ value,
1159
+ next: i + 1
1160
+ };
1161
+ shift += 7n;
1162
+ }
1163
+ return {
1164
+ value,
1165
+ next: i
1166
+ };
1167
+ }
1168
+ function deploymentIdFromLegacyZip(bytes, pathForError) {
1050
1169
  let entries;
1051
1170
  try {
1052
1171
  entries = unzipSync(bytes, { filter: (file) => file.name === "app.json" });
@@ -1195,6 +1314,7 @@ async function runDeploy(args, deps = {}) {
1195
1314
  workspaceId,
1196
1315
  appId,
1197
1316
  deploymentId,
1317
+ bundleFormat: bundleInfo.format,
1198
1318
  bytes: bundleInfo.bytes.byteLength,
1199
1319
  steps,
1200
1320
  memo: memo ?? null,
@@ -1279,6 +1399,7 @@ async function runDeploy(args, deps = {}) {
1279
1399
  workspaceId,
1280
1400
  appId,
1281
1401
  deploymentId,
1402
+ bundleFormat: bundleInfo.format,
1282
1403
  uploaded,
1283
1404
  reviewed,
1284
1405
  released: release,
@@ -5340,6 +5461,9 @@ async function fetchLatestReleaseConditional(previousEtag) {
5340
5461
  etag
5341
5462
  };
5342
5463
  }
5464
+ function findSha256SumsAsset(release) {
5465
+ return release.assets.find((a) => a.name === "SHA256SUMS");
5466
+ }
5343
5467
  function versionFromTag(tag) {
5344
5468
  const at = tag.lastIndexOf("@");
5345
5469
  const candidate = at >= 0 ? tag.slice(at + 1) : tag;
@@ -5403,6 +5527,31 @@ function compareSemver(a, b) {
5403
5527
  return pa.pre > pb.pre ? 1 : -1;
5404
5528
  }
5405
5529
  //#endregion
5530
+ //#region src/sha256.ts
5531
+ function parseSha256Sums(text) {
5532
+ const out = /* @__PURE__ */ new Map();
5533
+ for (const rawLine of text.split(/\r?\n/)) {
5534
+ const line = rawLine.trim();
5535
+ if (line === "" || line.startsWith("#")) continue;
5536
+ const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
5537
+ if (!match) continue;
5538
+ const hash = match[1]?.toLowerCase();
5539
+ const name = match[2]?.trim();
5540
+ if (!hash || !name) continue;
5541
+ out.set(name, hash);
5542
+ }
5543
+ return out;
5544
+ }
5545
+ function sha256OfFile(path) {
5546
+ return new Promise((resolve, reject) => {
5547
+ const hash = createHash("sha256");
5548
+ const stream = createReadStream(path);
5549
+ stream.on("error", reject);
5550
+ stream.on("data", (chunk) => hash.update(chunk));
5551
+ stream.on("end", () => resolve(hash.digest("hex")));
5552
+ });
5553
+ }
5554
+ //#endregion
5406
5555
  //#region src/version.ts
5407
5556
  function resolveVersion() {
5408
5557
  try {
@@ -5410,7 +5559,7 @@ function resolveVersion() {
5410
5559
  if (typeof injected === "string" && injected.length > 0) return injected;
5411
5560
  } catch {}
5412
5561
  try {
5413
- return "0.1.15";
5562
+ return "0.1.17";
5414
5563
  } catch {}
5415
5564
  return "0.0.0-dev";
5416
5565
  }
@@ -5531,6 +5680,50 @@ const upgradeCommand = defineCommand({
5531
5680
  }, `Failed to download new binary: ${err.message}`);
5532
5681
  process.exit(ExitCode.NetworkError);
5533
5682
  }
5683
+ const sumsAsset = findSha256SumsAsset(release);
5684
+ if (!sumsAsset) {
5685
+ await unlink(stagingPath).catch(() => {});
5686
+ emitError({
5687
+ reason: "sums-missing",
5688
+ tag: release.tag_name
5689
+ }, `Release ${release.tag_name} has no SHA256SUMS asset. It may still be uploading; retry shortly.`);
5690
+ process.exit(ExitCode.UpgradeChecksumFailed);
5691
+ }
5692
+ let expected;
5693
+ let actual;
5694
+ try {
5695
+ const sumsRes = await fetch(sumsAsset.browser_download_url);
5696
+ if (!sumsRes.ok) throw new Error(`SHA256SUMS download failed: ${sumsRes.status} ${sumsRes.statusText}`);
5697
+ expected = parseSha256Sums(await sumsRes.text()).get(platform.assetName);
5698
+ actual = (await sha256OfFile(stagingPath)).toLowerCase();
5699
+ } catch (err) {
5700
+ await unlink(stagingPath).catch(() => {});
5701
+ emitError({
5702
+ reason: "sums-fetch-failed",
5703
+ message: err.message
5704
+ }, `Failed to verify checksum: ${err.message}`);
5705
+ process.exit(ExitCode.UpgradeChecksumFailed);
5706
+ }
5707
+ if (!expected) {
5708
+ await unlink(stagingPath).catch(() => {});
5709
+ emitError({
5710
+ reason: "sums-no-entry",
5711
+ assetName: platform.assetName,
5712
+ tag: release.tag_name
5713
+ }, `SHA256SUMS for ${release.tag_name} has no entry for ${platform.assetName}.`);
5714
+ process.exit(ExitCode.UpgradeChecksumFailed);
5715
+ }
5716
+ if (expected.toLowerCase() !== actual) {
5717
+ await unlink(stagingPath).catch(() => {});
5718
+ emitError({
5719
+ reason: "sha256-mismatch",
5720
+ assetName: platform.assetName,
5721
+ expected: expected.toLowerCase(),
5722
+ actual
5723
+ }, `Checksum mismatch for ${platform.assetName}: expected ${expected.toLowerCase()}, got ${actual}.`);
5724
+ process.exit(ExitCode.UpgradeChecksumFailed);
5725
+ }
5726
+ if (!args.json) process.stdout.write("Checksum OK.\n");
5534
5727
  try {
5535
5728
  if (process.platform === "win32") {
5536
5729
  await rename(exePath, `${exePath}.old`);