@bifos/nhncloud-cli 0.20.0 → 0.21.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/README.md CHANGED
@@ -33,7 +33,7 @@ nhncloud skills install
33
33
 
34
34
  ## 빠른 시작
35
35
 
36
- Node.js 20 이상이 필요해요.
36
+ Node.js 20.18.1 이상이 필요해요.
37
37
 
38
38
  ### 1. 설치해요
39
39
 
@@ -143,6 +143,8 @@ nhncloud apigateway service list # API Gateway 서
143
143
  nhncloud apigateway stage deploy create <service-id> <stage-id> --yes # API Gateway 스테이지 배포
144
144
  nhncloud skm key list <keystore-id> # Secure Key Manager 키 목록
145
145
  nhncloud skm secret get <key-id> --quiet # 기밀 데이터 원문
146
+ nhncloud skm secret get <key-id> --field <name> --quiet # JSON 기밀 데이터의 필드 하나
147
+ printf '%s' "$VALUE" | nhncloud skm secret patch <key-id> <field> --replace --yes # JSON 기밀 데이터의 필드 하나만 바꾸기
146
148
  ```
147
149
 
148
150
  삭제처럼 되돌리기 어려운 일부 명령은 `--yes` 를 붙여야 실행돼요. 비대화형 환경에서 `--yes` 가 없으면 API 를 호출하기 전에 끝나요.
@@ -186,6 +188,7 @@ nhncloud logncrash available-token --json | jq -r '.availableToken'
186
188
 
187
189
  - 설정이 의심되면 `nhncloud doctor` 를 먼저 실행해 보세요.
188
190
  - 에이전트가 새 명령을 모르면 `nhncloud skills update` 를 실행해 주세요.
191
+ - 프록시를 거쳐야 하면 `--proxy http://proxy.example:3128` 또는 `HTTPS_PROXY` 를 지정해요. 자세한 규칙은 [공통 가이드](skills/nhncloud-cli/references/common.md)의 「프록시」 절에 있어요.
189
192
  - 종료 코드별 대처는 [문제 해결](skills/nhncloud-cli/references/troubleshooting.md)에 있어요.
190
193
  - 버그나 제안은 [GitHub Issues](https://github.com/jon890/nhncloud-cli/issues) 에 남겨 주세요. 설정 문제라면 `nhncloud doctor --json` 출력을 함께 붙여 주세요.
191
194
  비밀값은 출력에 들어가지 않지만 파일 경로에 사용자 이름이 보일 수 있으니 필요하면 가려 주세요.
package/dist/index.js CHANGED
@@ -420,6 +420,71 @@ function getRequestTimeoutMs() {
420
420
  return DEFAULT_TIMEOUT_MS;
421
421
  }
422
422
 
423
+ // src/api/proxy.ts
424
+ function nonEmpty(value) {
425
+ return value === void 0 || value === "" ? void 0 : value;
426
+ }
427
+ function readEnvPair(env, lower, upper) {
428
+ const lowerValue = nonEmpty(env[lower]);
429
+ if (lowerValue !== void 0) {
430
+ return { source: lower, value: lowerValue };
431
+ }
432
+ const upperValue = nonEmpty(env[upper]);
433
+ if (upperValue !== void 0) {
434
+ return { source: upper, value: upperValue };
435
+ }
436
+ return void 0;
437
+ }
438
+ function validateProxyUrl({ source, value }) {
439
+ let protocol;
440
+ try {
441
+ protocol = new URL(value).protocol;
442
+ } catch {
443
+ protocol = void 0;
444
+ }
445
+ if (protocol !== "http:" && protocol !== "https:") {
446
+ throw new NhnCloudCliError(
447
+ `${source} \uAC12\uC774 \uD504\uB85D\uC2DC URL \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4. http:// \uB610\uB294 https:// \uB85C \uC2DC\uC791\uD558\uB294 URL \uC744 \uC9C0\uC815\uD558\uC138\uC694.`,
448
+ EXIT_PARAM_ERROR
449
+ );
450
+ }
451
+ return value;
452
+ }
453
+ function resolveProxySettings(option, env) {
454
+ const noProxy = readEnvPair(env, "no_proxy", "NO_PROXY")?.value;
455
+ const optionValue = nonEmpty(option);
456
+ let settings;
457
+ if (optionValue !== void 0) {
458
+ const proxy = validateProxyUrl({ source: "--proxy", value: optionValue });
459
+ settings = { httpProxy: proxy, httpsProxy: proxy };
460
+ } else {
461
+ const https = readEnvPair(env, "https_proxy", "HTTPS_PROXY");
462
+ const http = readEnvPair(env, "http_proxy", "HTTP_PROXY");
463
+ if (https === void 0 && http === void 0) {
464
+ return void 0;
465
+ }
466
+ settings = {};
467
+ if (https !== void 0) {
468
+ settings.httpsProxy = validateProxyUrl(https);
469
+ }
470
+ if (http !== void 0) {
471
+ settings.httpProxy = validateProxyUrl(http);
472
+ }
473
+ }
474
+ if (noProxy !== void 0) {
475
+ settings.noProxy = noProxy;
476
+ }
477
+ return settings;
478
+ }
479
+ async function installProxy(settings) {
480
+ const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
481
+ const options = {};
482
+ if (settings.httpProxy !== void 0) options.httpProxy = settings.httpProxy;
483
+ if (settings.httpsProxy !== void 0) options.httpsProxy = settings.httpsProxy;
484
+ if (settings.noProxy !== void 0) options.noProxy = settings.noProxy;
485
+ setGlobalDispatcher(new EnvHttpProxyAgent(options));
486
+ }
487
+
423
488
  // src/commands/parse-options.ts
424
489
  var POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
425
490
  var NON_NEGATIVE_INTEGER_PATTERN = /^(0|[1-9]\d*)$/;
@@ -568,7 +633,7 @@ function configureGlobalOptionsHelp(root) {
568
633
  }
569
634
 
570
635
  // src/version.ts
571
- var CLI_VERSION = true ? "0.20.0" : "0.0.0-dev";
636
+ var CLI_VERSION = true ? "0.21.0" : "0.0.0-dev";
572
637
 
573
638
  // src/commands/configure.ts
574
639
  var import_commander2 = require("commander");
@@ -701,6 +766,13 @@ function toNhnCloudCliError(err) {
701
766
  );
702
767
  }
703
768
  if (err instanceof Error) {
769
+ const cause = err.cause;
770
+ if (cause instanceof Error && cause.message !== "" && cause.message !== err.message) {
771
+ return new NhnCloudCliError(
772
+ `${err.message} (${sanitizeForTerminal(cause.message)})`,
773
+ EXIT_API_ERROR
774
+ );
775
+ }
704
776
  return new NhnCloudCliError(err.message, EXIT_API_ERROR);
705
777
  }
706
778
  return new NhnCloudCliError(String(err), EXIT_API_ERROR);
@@ -12475,7 +12547,7 @@ function readStdin(stdin, spec) {
12475
12547
  }
12476
12548
  function readSkmInput(source, spec, stdin = processStdin) {
12477
12549
  const fileFlag = spec.fileFlag ?? "--file";
12478
- if (source.text !== void 0 && source.file !== void 0) {
12550
+ if (spec.textFlag !== void 0 && source.text !== void 0 && source.file !== void 0) {
12479
12551
  throw new NhnCloudCliError(`${spec.textFlag}\uC640 ${fileFlag}\uC740 \uD568\uAED8 \uC9C0\uC815\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`, EXIT_PARAM_ERROR);
12480
12552
  }
12481
12553
  let input;
@@ -12485,6 +12557,11 @@ function readSkmInput(source, spec, stdin = processStdin) {
12485
12557
  input = readInputFile(source.file, spec);
12486
12558
  } else if (!stdin.isTTY) {
12487
12559
  input = readStdin(stdin, spec);
12560
+ } else if (spec.textFlag === void 0) {
12561
+ throw new NhnCloudCliError(
12562
+ `${spec.label}\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. ${fileFlag} <\uACBD\uB85C>\uB098 \uD45C\uC900 \uC785\uB825(\uD30C\uC774\uD504)\uC73C\uB85C \uC804\uB2EC\uD558\uC138\uC694.`,
12563
+ EXIT_PARAM_ERROR
12564
+ );
12488
12565
  } else {
12489
12566
  throw new NhnCloudCliError(
12490
12567
  `${spec.label}\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. ${spec.textFlag} <\uAC12>, ${fileFlag} <\uACBD\uB85C>, \uD45C\uC900 \uC785\uB825(\uD30C\uC774\uD504) \uC911 \uD558\uB098\uB85C \uC804\uB2EC\uD558\uC138\uC694.`,
@@ -12940,18 +13017,153 @@ var keyCommand = new import_commander63.Command("key").description("SKM \uD0A4 \
12940
13017
 
12941
13018
  // src/commands/skm/secret.ts
12942
13019
  var import_commander64 = require("commander");
13020
+
13021
+ // src/commands/skm/secret-json.ts
13022
+ var import_node_crypto6 = require("crypto");
13023
+ var import_node_util = require("util");
13024
+ var NUMBER_LITERAL = /-?\d+(\.\d+)?([eE][+-]?\d+)?/y;
13025
+ var MAX_SIGNIFICANT_DIGITS = 15;
13026
+ function tryParseSecretObject(text) {
13027
+ let parsed;
13028
+ try {
13029
+ parsed = JSON.parse(text);
13030
+ } catch {
13031
+ return null;
13032
+ }
13033
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
13034
+ return parsed;
13035
+ }
13036
+ function parseSecretObject(text, usage) {
13037
+ const object = tryParseSecretObject(text);
13038
+ if (object === null) {
13039
+ throw new NhnCloudCliError(`\uAE30\uBC00 \uB370\uC774\uD130 \uAC12\uC774 JSON \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uC5B4\uC11C ${usage}\uB97C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`, EXIT_PARAM_ERROR);
13040
+ }
13041
+ return object;
13042
+ }
13043
+ function isLossyLiteral(literal) {
13044
+ const value = Number(literal);
13045
+ if (!Number.isFinite(value) || Object.is(value, -0)) return true;
13046
+ if (/^-?\d+$/.test(literal)) return !Number.isSafeInteger(value);
13047
+ const mantissa = literal.replace(/^-/, "").replace(/[eE].*$/, "").replace(".", "");
13048
+ return mantissa.replace(/^0+/, "").replace(/0+$/, "").length > MAX_SIGNIFICANT_DIGITS;
13049
+ }
13050
+ function hasLossyNumber(text) {
13051
+ let i = 0;
13052
+ while (i < text.length) {
13053
+ const ch = text[i];
13054
+ if (ch === '"') {
13055
+ i += 1;
13056
+ while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1;
13057
+ i += 1;
13058
+ continue;
13059
+ }
13060
+ if (ch === "-" || ch >= "0" && ch <= "9") {
13061
+ NUMBER_LITERAL.lastIndex = i;
13062
+ const match = NUMBER_LITERAL.exec(text);
13063
+ if (match === null) {
13064
+ i += 1;
13065
+ continue;
13066
+ }
13067
+ if (isLossyLiteral(match[0])) return true;
13068
+ i += match[0].length;
13069
+ continue;
13070
+ }
13071
+ i += 1;
13072
+ }
13073
+ return false;
13074
+ }
13075
+ function assertNoLossyNumber(text, usage) {
13076
+ if (hasLossyNumber(text)) {
13077
+ throw new NhnCloudCliError(
13078
+ `\uAE30\uBC00 \uB370\uC774\uD130\uC5D0 \uB2E4\uC2DC \uC4F0\uBA74 \uAC12\uC774 \uBC14\uB00C\uB294 \uC22B\uC790\uAC00 \uC788\uC5B4 ${usage}\uB97C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC804\uCCB4 \uAC12\uC740 --field \uC5C6\uC774 secret get\uC73C\uB85C \uBC1B\uACE0, \uBC14\uAFC0 \uB54C\uB294 secret update\uB85C \uC804\uCCB4 \uAC12\uC744 \uBCF4\uB0B4\uC138\uC694.`,
13079
+ EXIT_PARAM_ERROR
13080
+ );
13081
+ }
13082
+ }
13083
+ function formatFieldValue(value) {
13084
+ return typeof value === "string" ? value : JSON.stringify(value);
13085
+ }
13086
+ function parseFieldName(value, label) {
13087
+ if (value === "") {
13088
+ throw new NhnCloudCliError(`${label}\uB294 \uBE44\uC5B4 \uC788\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`, EXIT_PARAM_ERROR);
13089
+ }
13090
+ return value;
13091
+ }
13092
+ var DEFAULT_INDENT = " ";
13093
+ function serializeSecretObject(object, originalText) {
13094
+ if (!originalText.trimEnd().includes("\n")) return JSON.stringify(object);
13095
+ const indent = /\n([ \t]+)\S/.exec(originalText)?.[1] ?? DEFAULT_INDENT;
13096
+ const serialized = JSON.stringify(object, null, indent);
13097
+ return originalText.endsWith("\n") ? serialized + "\n" : serialized;
13098
+ }
13099
+ function sha256Hex(text) {
13100
+ return (0, import_node_crypto6.createHash)("sha256").update(text, "utf8").digest("hex");
13101
+ }
13102
+ function planSecretPatch(before, field, change) {
13103
+ const exists = Object.hasOwn(before, field);
13104
+ if (change.kind === "delete") {
13105
+ if (!exists) return { action: "unchanged", after: before };
13106
+ return { action: "deleted", after: Object.fromEntries(Object.entries(before).filter(([key]) => key !== field)) };
13107
+ }
13108
+ if (!exists) {
13109
+ return { action: "added", after: Object.fromEntries([...Object.entries(before), [field, change.value]]) };
13110
+ }
13111
+ if (before[field] === change.value) return { action: "unchanged", after: before };
13112
+ if (!change.replace) {
13113
+ throw new NhnCloudCliError(
13114
+ `\uD544\uB4DC ${JSON.stringify(field)}\uAC00 \uC774\uBBF8 \uC788\uC2B5\uB2C8\uB2E4. \uAC12\uC744 \uBC14\uAFB8\uB824\uBA74 --replace\uB97C \uBD99\uC774\uC138\uC694.`,
13115
+ EXIT_PARAM_ERROR
13116
+ );
13117
+ }
13118
+ return {
13119
+ action: "replaced",
13120
+ after: Object.fromEntries(
13121
+ Object.entries(before).map(([key, value]) => [key, key === field ? change.value : value])
13122
+ )
13123
+ };
13124
+ }
13125
+ function entriesWithout(object, field) {
13126
+ return Object.entries(object).filter(([key]) => key !== field);
13127
+ }
13128
+ function verifySecretPatch(before, fetched, field, change) {
13129
+ if (fetched === null) return { fieldVerified: false, othersVerified: false };
13130
+ let fieldVerified;
13131
+ if (change.kind === "delete") {
13132
+ fieldVerified = !Object.hasOwn(fetched, field);
13133
+ } else {
13134
+ const value = Object.hasOwn(fetched, field) ? fetched[field] : void 0;
13135
+ fieldVerified = typeof value === "string" && sha256Hex(value) === sha256Hex(change.value);
13136
+ }
13137
+ const expected = entriesWithout(before, field);
13138
+ const actual = new Map(entriesWithout(fetched, field));
13139
+ const othersVerified = expected.length === actual.size && expected.every(([key, value]) => actual.has(key) && (0, import_node_util.isDeepStrictEqual)(actual.get(key), value));
13140
+ return { fieldVerified, othersVerified };
13141
+ }
13142
+
13143
+ // src/commands/skm/secret.ts
12943
13144
  var getCommand13 = withSkmOptions(
12944
- new import_commander64.Command("get").description("\uAE30\uBC00 \uB370\uC774\uD130\uB97C \uC870\uD68C\uD55C\uB2E4 (\uC6D0\uBB38\uC744 stdout\uC5D0 \uCD9C\uB825)").argument("<key-id>", "\uD0A4 ID")
13145
+ new import_commander64.Command("get").description("\uAE30\uBC00 \uB370\uC774\uD130\uB97C \uC870\uD68C\uD55C\uB2E4 (\uC6D0\uBB38\uC744 stdout\uC5D0 \uCD9C\uB825, --field\uB85C JSON \uAC1D\uCCB4\uC758 \uD544\uB4DC \uD558\uB098\uB9CC)").argument("<key-id>", "\uD0A4 ID").option("--field <name>", "JSON \uAC1D\uCCB4 \uAC12\uC5D0\uC11C \uC774 \uD544\uB4DC\uC758 \uAC12\uB9CC \uCD9C\uB825\uD55C\uB2E4")
12945
13146
  ).action(async (keyId, _opts, command) => {
12946
13147
  const opts = command.optsWithGlobals();
12947
13148
  const parsedKeyId = parseRequiredArgument(keyId, "key-id");
12948
13149
  const macAddress = parseMacAddressOption(opts.macAddress);
13150
+ const field = opts.field === void 0 ? void 0 : parseFieldName(opts.field, "--field");
12949
13151
  const { client } = await resolveSkmClient({ profile: opts.profile, macAddress });
12950
13152
  const secret = await withSkmSpinner(
12951
13153
  `SKM \uAE30\uBC00 \uB370\uC774\uD130 ${formatCell(parsedKeyId)} \uC870\uD68C \uC911...`,
12952
13154
  () => client.getSecret(parsedKeyId)
12953
13155
  );
12954
- printSkmValue(opts, secret, { secret });
13156
+ if (field === void 0) {
13157
+ printSkmValue(opts, secret, { secret });
13158
+ return;
13159
+ }
13160
+ const object = parseSecretObject(secret, "--field");
13161
+ if (!Object.hasOwn(object, field)) {
13162
+ throw new NhnCloudCliError(`\uAE30\uBC00 \uB370\uC774\uD130\uC5D0 \uD544\uB4DC ${JSON.stringify(field)}\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`, EXIT_PARAM_ERROR);
13163
+ }
13164
+ const value = object[field];
13165
+ if (typeof value !== "string") assertNoLossyNumber(secret, "--field");
13166
+ printSkmValue(opts, formatFieldValue(value), { keyId: parsedKeyId, field, value });
12955
13167
  });
12956
13168
  var updateCommand5 = withSkmOptions(
12957
13169
  new import_commander64.Command("update").description("\uAE30\uBC00 \uB370\uC774\uD130 \uAC12\uC744 \uBC14\uAFBC\uB2E4 (\uAC12\uC740 --value, --file, \uD45C\uC900 \uC785\uB825 \uC911 \uD558\uB098)").argument("<key-id>", "\uD0A4 ID").option("--value <value>", "\uC0C8 \uAE30\uBC00 \uB370\uC774\uD130 \uAC12").option("--file <path>", "\uC0C8 \uAE30\uBC00 \uB370\uC774\uD130 \uAC12\uC744 \uC77D\uC744 \uD30C\uC77C")
@@ -12972,7 +13184,94 @@ var updateCommand5 = withSkmOptions(
12972
13184
  const { secretValue: _omitted, ...result } = updated;
12973
13185
  outputSkmWriteResult(opts, result, [formatCell(updated.keyId)]);
12974
13186
  });
12975
- var secretCommand = new import_commander64.Command("secret").description("SKM \uAE30\uBC00 \uB370\uC774\uD130 \uC870\uD68C\uC640 \uC218\uC815").addCommand(getCommand13).addCommand(updateCommand5);
13187
+ var patchCommand2 = withSkmOptions(
13188
+ new import_commander64.Command("patch").description("JSON \uAC1D\uCCB4 \uAE30\uBC00 \uB370\uC774\uD130\uC758 \uD544\uB4DC \uD558\uB098\uB97C \uB354\uD558\uAC70\uB098 \uBC14\uAFB8\uAC70\uB098 \uC9C0\uC6B4\uB2E4 (\uAE30\uBCF8\uC740 \uBBF8\uB9AC\uBCF4\uAE30, --yes\uB85C \uBC18\uC601)").argument("<key-id>", "\uD0A4 ID").argument("<field>", "\uD544\uB4DC \uC774\uB984 (\uCD5C\uC0C1\uC704 \uD0A4)").option("--file <path>", "\uD544\uB4DC\uC5D0 \uB123\uC744 \uAC12\uC744 \uC77D\uC744 \uD30C\uC77C (\uC5C6\uC73C\uBA74 \uD45C\uC900 \uC785\uB825)").option("--replace", "\uC774\uBBF8 \uC788\uB294 \uD544\uB4DC\uC758 \uAC12\uC744 \uBC14\uAFBC\uB2E4").option("--delete", "\uD544\uB4DC\uB97C \uC9C0\uC6B4\uB2E4").option("--yes", "\uBBF8\uB9AC\uBCF4\uAE30 \uB300\uC2E0 \uBC18\uC601\uD55C\uB2E4")
13189
+ ).action(async (keyId, field, _opts, command) => {
13190
+ const opts = command.optsWithGlobals();
13191
+ const parsedKeyId = parseRequiredArgument(keyId, "key-id");
13192
+ const parsedField = parseFieldName(field, "<field>");
13193
+ const macAddress = parseMacAddressOption(opts.macAddress);
13194
+ if (opts.delete && opts.replace) {
13195
+ throw new NhnCloudCliError("--delete\uC640 --replace\uB294 \uD568\uAED8 \uC9C0\uC815\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.", EXIT_PARAM_ERROR);
13196
+ }
13197
+ if (opts.delete && opts.file !== void 0) {
13198
+ throw new NhnCloudCliError("--delete\uC640 --file\uC740 \uD568\uAED8 \uC9C0\uC815\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.", EXIT_PARAM_ERROR);
13199
+ }
13200
+ let change;
13201
+ if (opts.delete) {
13202
+ change = { kind: "delete" };
13203
+ } else {
13204
+ const label = "\uD544\uB4DC\uC5D0 \uB123\uC744 \uB370\uC774\uD130";
13205
+ const input = readSkmInput({ file: opts.file }, { label, maxBytes: MAX_JSON_INPUT_BYTES });
13206
+ const value = decodeUtf8Input(input, label, "\uBC14\uC774\uB108\uB9AC\uB294 base64\uB85C \uC778\uCF54\uB529\uD574 \uC804\uB2EC\uD558\uC138\uC694.");
13207
+ if (value.endsWith("\n")) {
13208
+ process.stderr.write(
13209
+ "\uACBD\uACE0: \uD544\uB4DC\uC5D0 \uB123\uC744 \uB370\uC774\uD130\uAC00 \uC904\uBC14\uAFC8\uC73C\uB85C \uB05D\uB0A9\uB2C8\uB2E4. \uC904\uBC14\uAFC8\uB3C4 \uAC12\uC5D0 \uB4E4\uC5B4\uAC11\uB2C8\uB2E4. \uBE7C\uB824\uBA74 printf '%s'\uB85C \uB118\uAE30\uC138\uC694.\n"
13210
+ );
13211
+ }
13212
+ change = { kind: "set", value, replace: opts.replace === true };
13213
+ }
13214
+ const { client } = await resolveSkmClient({ profile: opts.profile, macAddress });
13215
+ const id = formatCell(parsedKeyId);
13216
+ const current2 = await withSkmSpinner(`SKM \uAE30\uBC00 \uB370\uC774\uD130 ${id} \uC870\uD68C \uC911...`, () => client.getSecret(parsedKeyId));
13217
+ const before = parseSecretObject(current2, "secret patch");
13218
+ assertNoLossyNumber(current2, "secret patch");
13219
+ const { action, after } = planSecretPatch(before, parsedField, change);
13220
+ const report = (applied, verified2) => {
13221
+ const result = {
13222
+ keyId: parsedKeyId,
13223
+ field: parsedField,
13224
+ action,
13225
+ applied,
13226
+ fieldVerified: verified2?.fieldVerified ?? null,
13227
+ othersVerified: verified2?.othersVerified ?? null
13228
+ };
13229
+ outputSkmWriteResult(opts, result, [action]);
13230
+ };
13231
+ if (!opts.yes || action === "unchanged") {
13232
+ report(false, null);
13233
+ if (!opts.yes && action !== "unchanged") {
13234
+ process.stderr.write("\uBBF8\uB9AC\uBCF4\uAE30\uC785\uB2C8\uB2E4. \uBC18\uC601\uD558\uB824\uBA74 --yes\uB97C \uBD99\uC774\uC138\uC694.\n");
13235
+ }
13236
+ return;
13237
+ }
13238
+ try {
13239
+ await withSkmSpinner(
13240
+ `SKM \uAE30\uBC00 \uB370\uC774\uD130 ${id} \uC218\uC815 \uC911...`,
13241
+ () => client.updateSecret(parsedKeyId, serializeSecretObject(after, current2))
13242
+ );
13243
+ } catch (err) {
13244
+ const e = toNhnCloudCliError(err);
13245
+ throw new NhnCloudCliError(
13246
+ `\uAE30\uBC00 \uB370\uC774\uD130 \uC218\uC815 \uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4: ${e.message} \uBC18\uC601\uB410\uB294\uC9C0\uB294 \uAC19\uC740 \uBA85\uB839\uC744 --yes \uC5C6\uC774 \uB2E4\uC2DC \uC2E4\uD589\uD574 action\uC73C\uB85C \uD655\uC778\uD558\uC138\uC694.`,
13247
+ e.exitCode
13248
+ );
13249
+ }
13250
+ let fetchedText;
13251
+ try {
13252
+ fetchedText = await withSkmSpinner(`SKM \uAE30\uBC00 \uB370\uC774\uD130 ${id} \uB2E4\uC2DC \uC870\uD68C \uC911...`, () => client.getSecret(parsedKeyId));
13253
+ } catch (err) {
13254
+ const e = toNhnCloudCliError(err);
13255
+ report(true, null);
13256
+ throw new NhnCloudCliError(
13257
+ `\uAE30\uBC00 \uB370\uC774\uD130\uB294 \uBC14\uAFE8\uC9C0\uB9CC \uB2E4\uC2DC \uC870\uD68C\uD558\uC9C0 \uBABB\uD574 \uAC80\uC99D\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${e.message} skm secret get\uC73C\uB85C \uD604\uC7AC \uAC12\uC744 \uD655\uC778\uD558\uC138\uC694.`,
13258
+ EXIT_API_ERROR
13259
+ );
13260
+ }
13261
+ const verified = verifySecretPatch(before, tryParseSecretObject(fetchedText), parsedField, change);
13262
+ report(true, verified);
13263
+ const mismatched = [
13264
+ ...verified.fieldVerified ? [] : [`\uD544\uB4DC ${JSON.stringify(parsedField)}`],
13265
+ ...verified.othersVerified ? [] : ["\uB2E4\uB978 \uD544\uB4DC"]
13266
+ ];
13267
+ if (mismatched.length > 0) {
13268
+ throw new NhnCloudCliError(
13269
+ `\uBC18\uC601 \uB4A4 \uB2E4\uC2DC \uC870\uD68C\uD55C \uAE30\uBC00 \uB370\uC774\uD130\uAC00 \uAE30\uB300\uC640 \uB2E4\uB985\uB2C8\uB2E4 (${mismatched.join(", ")}). \uB2E4\uB978 \uC4F0\uAE30\uAC00 \uB07C\uC5B4\uB4E4\uC5C8\uAC70\uB098 \uC2B9\uC778 \uAE30\uB2A5 \uB54C\uBB38\uC5D0 \uBCC0\uACBD\uC774 \uB300\uAE30 \uC911\uC77C \uC218 \uC788\uC2B5\uB2C8\uB2E4. skm secret get\uC73C\uB85C \uD604\uC7AC \uAC12\uC744 \uD655\uC778\uD558\uC138\uC694.`,
13270
+ EXIT_API_ERROR
13271
+ );
13272
+ }
13273
+ });
13274
+ var secretCommand = new import_commander64.Command("secret").description("SKM \uAE30\uBC00 \uB370\uC774\uD130 \uC870\uD68C\uC640 \uC218\uC815").addCommand(getCommand13).addCommand(updateCommand5).addCommand(patchCommand2);
12976
13275
 
12977
13276
  // src/commands/skm/symmetric-key.ts
12978
13277
  var import_commander65 = require("commander");
@@ -14086,8 +14385,8 @@ Agent workflow:
14086
14385
  3. nhncloud ncs workload get <id> --json
14087
14386
  `;
14088
14387
  var program = new import_commander72.Command();
14089
- program.name("nhncloud").description("NHN Cloud CLI \u2014 AI agent & terminal friendly").version(CLI_VERSION).option("--json", "JSON \uD615\uC2DD\uC73C\uB85C \uCD9C\uB825").option("--quiet", "\uCD5C\uC18C \uCD9C\uB825 (\uC790\uB3D9\uD654\uC6A9)").option("--no-color", "\uC0C9\uC0C1 \uBE44\uD65C\uC131\uD654").option("--request-timeout <sec>", "HTTP \uC694\uCCAD \uD0C0\uC784\uC544\uC6C3 (\uCD08, \uAE30\uBCF8 30, \uBC94\uC704 1~3600). NHNCLOUD_REQUEST_TIMEOUT \uB85C\uB3C4 \uC9C0\uC815").addHelpText("after", rootAgentHints);
14090
- program.hook("preAction", () => {
14388
+ program.name("nhncloud").description("NHN Cloud CLI \u2014 AI agent & terminal friendly").version(CLI_VERSION).option("--json", "JSON \uD615\uC2DD\uC73C\uB85C \uCD9C\uB825").option("--quiet", "\uCD5C\uC18C \uCD9C\uB825 (\uC790\uB3D9\uD654\uC6A9)").option("--no-color", "\uC0C9\uC0C1 \uBE44\uD65C\uC131\uD654").option("--request-timeout <sec>", "HTTP \uC694\uCCAD \uD0C0\uC784\uC544\uC6C3 (\uCD08, \uAE30\uBCF8 30, \uBC94\uC704 1~3600). NHNCLOUD_REQUEST_TIMEOUT \uB85C\uB3C4 \uC9C0\uC815").option("--proxy <url>", "HTTP \uC694\uCCAD\uC5D0 \uC4F8 \uD504\uB85D\uC2DC URL. HTTPS_PROXY\xB7HTTP_PROXY \uBCF4\uB2E4 \uC6B0\uC120\uD558\uACE0 NO_PROXY \uB294 \uADF8\uB300\uB85C \uB530\uB978\uB2E4").addHelpText("after", rootAgentHints);
14389
+ program.hook("preAction", async () => {
14091
14390
  const opts = program.opts();
14092
14391
  const rawTimeout = opts.requestTimeout ?? process.env["NHNCLOUD_REQUEST_TIMEOUT"];
14093
14392
  const timeoutValue = rawTimeout === "" ? void 0 : rawTimeout;
@@ -14096,6 +14395,7 @@ program.hook("preAction", () => {
14096
14395
  opts.requestTimeout !== void 0 ? "--request-timeout" : "NHNCLOUD_REQUEST_TIMEOUT",
14097
14396
  { min: 1, max: 3600 }
14098
14397
  );
14398
+ const proxySettings = resolveProxySettings(opts.proxy, process.env);
14099
14399
  if (!opts.color || process.env["NO_COLOR"]) {
14100
14400
  import_chalk18.default.level = 0;
14101
14401
  }
@@ -14105,6 +14405,9 @@ program.hook("preAction", () => {
14105
14405
  if (timeoutSec !== void 0) {
14106
14406
  setRequestTimeoutMs(timeoutSec * 1e3);
14107
14407
  }
14408
+ if (proxySettings) {
14409
+ await installProxy(proxySettings);
14410
+ }
14108
14411
  });
14109
14412
  program.addCommand(configureCommand);
14110
14413
  var logncrashCommand = new import_commander72.Command("logncrash").description("Log & Crash \uAD00\uB828 \uBA85\uB839").addHelpText("after", logncrashAgentWorkflow);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bifos/nhncloud-cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "CLI for NHN Cloud (Compute, Network, Kubernetes, Load Balancer, Log & Crash, Deploy, Secure Key Manager and more) — AI agent & terminal friendly",
5
5
  "keywords": [
6
6
  "nhncloud",
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "packageManager": "pnpm@11.18.0",
39
39
  "engines": {
40
- "node": ">=20"
40
+ "node": ">=20.18.1"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"
@@ -58,7 +58,8 @@
58
58
  "cli-table3": "^0.6.5",
59
59
  "commander": "^14.0.3",
60
60
  "ky": "^1.14.3",
61
- "ora": "^9.3.0"
61
+ "ora": "^9.3.0",
62
+ "undici": "^7.29.1"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@types/node": "^25.5.0",
@@ -248,6 +248,32 @@ deploy 바이너리 업로드·다운로드의 상한은 최소 600초를 유지
248
248
  `instance create --timeout` 과 `ncs workload create --timeout` 은 상태 폴링 대기 시간을 지정한다.
249
249
  HTTP 요청 상한과는 다른 설정이므로 서로 대체하지 않는다.
250
250
 
251
+ ## 프록시
252
+
253
+ 외부로 나가는 길이 프록시뿐인 서버에서는 전역 옵션이나 환경변수로 프록시를 지정한다.
254
+ 토큰 발급을 포함한 모든 HTTP 요청이 같은 프록시를 거친다.
255
+
256
+ ```bash
257
+ nhncloud --proxy http://proxy.example:3128 skm confirm --json
258
+ HTTPS_PROXY=http://proxy.example:3128 nhncloud skm confirm --json
259
+ ```
260
+
261
+ 우선순위는 다음과 같다.
262
+
263
+ 1. `--proxy <url>`: HTTP와 HTTPS 요청 모두에 쓴다.
264
+ 2. 소문자 환경변수 `https_proxy`, `http_proxy`
265
+ 3. 대문자 환경변수 `HTTPS_PROXY`, `HTTP_PROXY`
266
+
267
+ - `HTTPS_PROXY` 가 없으면 HTTPS 요청에도 `HTTP_PROXY` 를 쓴다.
268
+ - `NO_PROXY`(또는 `no_proxy`)에 적은 host 는 프록시를 거치지 않는다. `--proxy` 를 줘도 `NO_PROXY` 는 그대로 적용된다.
269
+ - 빈 값은 지정하지 않은 것으로 본다. 프록시 값이 하나도 없으면 직접 연결한다.
270
+ - 값은 `http://` 또는 `https://` 로 시작하는 URL 이어야 한다. 아니면 API 호출 전에 종료 코드 3으로 실패하고, 오류 문구에는 값 대신 옵션이나 환경변수 이름만 나온다.
271
+ 이 검사는 모든 명령 앞에서 하므로 `skills`, `commands`, `configure`, `doctor` 처럼 HTTP 요청이 없는 명령도 종료 코드 3으로 끝난다.
272
+ - 프록시에 연결하지 못하면 종료 코드 1로 실패하고, 오류 문구 뒤 괄호에 원인이 붙는다(예: `fetch failed (connect ECONNREFUSED ...)`).
273
+
274
+ 프록시 인증이 필요하면 `http://user:password@proxy.example:3128` 형식으로 사용자 정보를 URL 에 넣는다.
275
+ 비밀번호가 셸 이력에 남지 않도록 `--proxy` 대신 환경변수로 넘긴다.
276
+
251
277
  ## Command catalog
252
278
 
253
279
  `nhncloud commands`는 command path, argument, option, description을 출력한다. 외부 API를 호출하지 않는다.
@@ -264,7 +290,7 @@ nhncloud commands --json | jq '.commands[] | select(.path=="nks cluster list")'
264
290
 
265
291
  | 확인 방식 | 명령 |
266
292
  |---|---|
267
- | TTY 여부와 관계없이 `--yes` 필수 | `apigateway stage update`, `apigateway stage import-resources`, `apigateway stage deploy create`, `apigateway stage deploy rollback`, `apigateway resource set-path-plugin`, `apigateway resource set-method-plugin` (`--dry-run` 제외), `loadbalancer ipacl delete`, `loadbalancer ipacl target add`, `loadbalancer ipacl target remove`, `loadbalancer set-ipacl`, `loadbalancer clear-ipacl`, `network security-group delete`, `network security-group rule delete`, `instance security-group add`, `instance security-group remove`, `skm key delete`, `skm key purge`, `skm keystore delete`, `skm keystore auth delete`, `skm keystore auth purge` |
293
+ | TTY 여부와 관계없이 `--yes` 필수 | `apigateway stage update`, `apigateway stage import-resources`, `apigateway stage deploy create`, `apigateway stage deploy rollback`, `apigateway resource set-path-plugin`, `apigateway resource set-method-plugin` (`--dry-run` 제외), `loadbalancer ipacl delete`, `loadbalancer ipacl target add`, `loadbalancer ipacl target remove`, `loadbalancer set-ipacl`, `loadbalancer clear-ipacl`, `network security-group delete`, `network security-group rule delete`, `instance security-group add`, `instance security-group remove`, `skm key delete`, `skm key purge`, `skm keystore delete`, `skm keystore auth delete`, `skm keystore auth purge`, `skm secret patch` (`--yes` 없으면 미리보기) |
268
294
  | 비대화형 환경에서는 `--yes` 필수, TTY에서는 확인 질문 | `instance delete`, `floatingip delete`, `nks cluster delete`, `nks cluster addon remove`, `nks nodegroup delete`, `ncs template delete`, `ncs template version delete`, `ncs workload delete` |
269
295
  | 확인 질문과 `--yes` 없음 | `instance keypair delete`, `deploy run`, `nks cluster resize --nodes-to-remove`, `nks nodegroup upgrade`, `ncs workload update` (PUT 전체 교체), `instance volume detach`, `instance resize`, `instance stop`, `instance reboot`, `nks nodegroup update-flavor`, `nks cluster set-ipacl`, `nks cluster set-control-plane-log`, `nks cluster update-sgw`, `ncs workload patch` (JSON Patch `remove` 포함 가능), `nks nodegroup stop-node` (지정 노드 중지), `ncs workload pause` (workload 일시정지), `ncs workload restart` (task 재시작), `instance resize-confirm` (새 flavor로 고정, 이후 `resize-revert` 불가), `skm secret update` (기밀 데이터 값 교체), `skm keystore update` (실행 위치가 키를 쓰지 못하게 될 수 있으므로 먼저 `skm confirm`) |
270
296
 
@@ -31,6 +31,7 @@ nhncloud skm keystore list
31
31
  nhncloud skm key list <keystore-id>
32
32
  nhncloud skm key list <keystore-id> --status active # active|inactive 로 거른다
33
33
  nhncloud skm secret get <key-id> --quiet
34
+ nhncloud skm secret get <key-id> --field <name> --quiet
34
35
  ```
35
36
 
36
37
  `--quiet`가 출력하는 값은 다음과 같다.
@@ -38,6 +39,7 @@ nhncloud skm secret get <key-id> --quiet
38
39
  - `keystore list`·`keystore get`: `keyStoreId`
39
40
  - `key list`·`key get`: `keyId`
40
41
  - `secret get`: 기밀 데이터
42
+ - `secret get --field`: 필드 값(문자열이면 원문, 그 밖은 JSON 표기)
41
43
  - `symmetric-key get`: 대칭키 원문
42
44
  - `symmetric-key encrypt`: 암호문
43
45
  - `symmetric-key decrypt`: 평문
@@ -46,6 +48,7 @@ nhncloud skm secret get <key-id> --quiet
46
48
  - `symmetric-key create-local-key`: 평문 키와 암호화된 키 두 줄
47
49
  - `asymmetric-key verify`: 출력 없음
48
50
  - `key create`·`secret update`·`key delete|purge`: `keyId`
51
+ - `secret patch`: `action`(`added`, `replaced`, `deleted`, `unchanged`)
49
52
  - `keystore create|update|delete`: `keyStoreId`
50
53
  - `keystore auth add|delete|purge`: 입력한 IPv4·MAC 값(소문자로 정규화) 또는 인증서 이름
51
54
  - `keystore auth get`: 조회한 인증 정보의 값 또는 인증서 이름
@@ -70,6 +73,26 @@ printf '%s' "$SECRET" | nhncloud skm key create <keystore-id> --type secret --na
70
73
  - 대칭키·비대칭키는 자동 회전 없이 만들어진다. 자동 회전은 콘솔에서 설정한다.
71
74
  - MAC 값은 소문자로 바꿔 보내므로 콘솔에서 대문자로 등록한 MAC은 CLI로 지우지 못할 수 있다.
72
75
 
76
+ 값이 JSON 객체인 기밀 데이터는 `secret patch`로 최상위 필드 하나만 더하거나 바꾸거나 지운다.
77
+
78
+ ```bash
79
+ printf '%s' "$NEW_PASSWORD" | nhncloud skm secret patch <key-id> db_password --replace # 미리보기
80
+ printf '%s' "$NEW_PASSWORD" | nhncloud skm secret patch <key-id> db_password --replace --yes # 반영
81
+ nhncloud skm secret patch <key-id> old_field --delete --yes
82
+ ```
83
+
84
+ - 값이 JSON 객체인 기밀 데이터에만 쓴다. 값이 JSON 객체가 아니거나, 다시 쓰면 바뀌는 숫자(안전 정수 범위 밖의 정수, 유효 숫자 15자리를 넘는 소수)가 있으면 종료 코드 3이다.
85
+ - 새 값은 `--file`이나 표준 입력으로만 받고 JSON 문자열로 들어간다. 끝 줄바꿈도 값에 들어가므로 `echo` 대신 `printf '%s'`로 넘긴다.
86
+ - 기본은 미리보기라 동작(`action`)만 출력하고 쓰지 않는다. `--yes`를 줄 때만 전체 값을 쓴다. 동작이 `unchanged`면 `--yes`를 줘도 쓰지 않는다.
87
+ - 이미 있는 필드의 값을 다른 값으로 바꾸려면 `--replace`가 필요하다. 없으면 미리보기에서도 종료 코드 3이다. `--delete`는 `--file`·`--replace`와 함께 쓸 수 없고, 지울 필드가 없으면 `unchanged`다.
88
+ - 반영 뒤 같은 키를 다시 조회해 바꾼 필드와 다른 필드를 대조하고, 결과를 `fieldVerified`, `othersVerified`로 낸다. 어긋나거나 다시 조회하지 못하면 결과를 출력한 뒤 종료 코드 1로 끝난다.
89
+ - 실패 문구에 따라 할 일이 다르다.
90
+ - 쓰기 요청 실패: 반영 여부를 알 수 없다. 같은 명령을 `--yes` 없이 다시 실행해 `action`이 `unchanged`인지 확인한다.
91
+ - 다시 조회 실패: 값은 바꿨지만 검증하지 못했다. `secret get`으로 현재 값을 확인한다.
92
+ - 대조 불일치: 다른 쓰기가 끼어들었거나 승인 기능 때문에 변경이 대기 중일 수 있다. `secret get`으로 현재 값을 확인한다.
93
+ - 기밀 데이터에는 버전이 없다. 같은 키를 동시에 고치면 나중에 쓴 쪽이 앞의 변경을 덮어쓰고, 덮어쓴 값은 되돌릴 수 없다.
94
+ - 같은 이름의 키가 둘 이상이면 뒤의 것만 남는다. 들여쓰기와 끝 줄바꿈은 원래 값을 따르고, 그 밖의 공백, 이스케이프, 숫자 표기(`1.0` 은 `1`)는 다시 쓴다. 필드 순서는 유지하고 새 필드는 끝에 붙는다.
95
+
73
96
  ## 입력과 크기 한도
74
97
 
75
98
  암복호화·서명·검증 명령의 입력은 `--plaintext`·`--ciphertext`, `--file`, 표준 입력 순서로 고른다.
@@ -81,7 +104,9 @@ printf '%s' "$SECRET" | nhncloud skm key create <keystore-id> --type secret --na
81
104
  - `symmetric-key decrypt`: 암호문 1MB(1,000,000바이트)
82
105
  - `asymmetric-key sign`·`verify`: 245바이트, `--standard`를 주면 64KB(65536바이트)
83
106
  - `key create --type secret`·`secret update`: 기밀 데이터 값 1MB(1,000,000바이트)
107
+ - `secret patch`: 필드에 넣을 값 1MB(1,000,000바이트)
84
108
  - 인증서 비밀번호: 1024바이트
109
+ - 기밀 데이터는 공식 문서 기준 32KB 이하 텍스트이며, 이 상한은 서버가 판정한다.
85
110
  - `--standard`는 바이너리 입력을 base64로 바꿔 보낸다.
86
111
 
87
112
  ```bash
@@ -98,8 +123,15 @@ printf 'hello' | nhncloud skm symmetric-key encrypt <key-id> --quiet
98
123
  ```bash
99
124
  nhncloud skm secret get <key-id> --json | jq -j '.secret' > secret.txt
100
125
  ```
126
+
127
+ `secret get --field <name>`은 값이 JSON 객체인 기밀 데이터에서 최상위 필드 하나만 낸다.
128
+ `--json` 출력은 `{ keyId, field, value }`이고 `value`는 필드의 원래 JSON 타입이다.
129
+ 필드가 없거나 값이 JSON 객체가 아니면 종료 코드 3이다.
130
+ 문자열이 아닌 필드의 숫자가 정밀도를 잃을 수 있으면(안전 정수 범위를 넘거나 유효 숫자 15자리를 넘으면) 이때도 종료 코드 3이다.
131
+
101
132
  인증서 인증 상세의 `password`는 `***`로 가린다.
102
133
  `secret update`는 바꾼 값을 출력하지 않는다.
134
+ `secret patch`는 값과 해시를 출력하지 않는다. 출력은 `keyId`, `field`, `action`, `applied`, `fieldVerified`, `othersVerified`뿐이다.
103
135
  `symmetric-key get`은 대칭키 원문을 stdout에 낸다.
104
136
  `key get`, `keystore get`, `keystore auth get`은 메타데이터만 출력하고 키 원문과 인증서 비밀번호는 내지 않는다.
105
137
  비밀값 명령의 stdout을 로그나 이슈, 채팅에 붙이지 않는다.