@wport/cli 0.8.0 → 0.9.1-dev.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/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@
5
5
 
6
6
  ---
7
7
 
8
+ ## [0.9.0] — 2026-08-10
9
+
10
+ ### Added
11
+
12
+ - **個人線(pm_47)· `wport personal apply` 投遞**(對接 talent `/api/v1/personal/applications`):
13
+ - 單筆:`personal apply <job_enc_id> --resume <enc_id> --message <text> | --message-file <path|-> --confirm`
14
+ ——message 互斥二擇一必填、trim 後 1~2000 字**本地先驗**(超長/空 exit 2 不發請求)、`-` 讀 stdin;
15
+ 投遞對雇主可見,無 `--confirm` 一律 exit 2 不發請求;自動附 `Idempotency-Key`。
16
+ - 批次:`personal apply --file <batch.json> --confirm`——`{"applications":[…]}` ≤20 筆與逐筆 message
17
+ 本地先驗(超限 exit 2);輸出 succeeded/failed 表,`--output json` 給完整陣列。
18
+ - **錯誤分流**:冷卻 409(印 `next_apply_at`)、職缺終態 409、日上限 429(印剩餘額度 / `resets_at`)
19
+ → 統一 exit 3 + 人類可讀;其餘 4xx 沿既有 exit code 契約。
20
+ - codegen:凍結 `docs/handoff/swagger-wport-personal-api-v1.{json,yaml}` 納入 applications 端點,
21
+ `PERSONAL_APPLICATIONS_BASE` 常數;`gen:openapi:personal` 經 `--keep-prefix` 自動含 applications。
22
+
8
23
  ## [0.8.0] — 2026-07-30
9
24
 
10
25
  ### Added
package/README.md CHANGED
@@ -16,6 +16,16 @@ npm install -g @wport/cli
16
16
 
17
17
  Requires Node.js >= 18.17.
18
18
 
19
+ ### dev channel(內部測試用)
20
+
21
+ `@wport/cli@dev` 是預設連 dev 後端(`https://developers.wport.me/v2`)的建置,供內部測試未上 prod 的後端改動:
22
+
23
+ npm i -g @wport/cli@dev # wport → dev 後端
24
+ wport login # 直接對 dev 後端做 device flow
25
+ npm i -g @wport/cli@latest # 換回 prod 版
26
+
27
+ dev 版憑證獨立存於 `credentials-dev.json`,與 prod 版的 `credentials.json` 互不干擾;`wport doctor` 會印出目前 channel。base URL 仍可用 `--api` / `WPORT_API_BASE` 覆寫。
28
+
19
29
  ## Quick start
20
30
 
21
31
  ```bash
@@ -139,6 +149,11 @@ wport personal resumes publish <enc_id> # searchable by companies (
139
149
  wport personal resumes delete <enc_id> --confirm
140
150
  wport personal resumes export --out ./backup
141
151
 
152
+ # Apply to jobs (visible to the employer; requires --confirm; 20/day)
153
+ wport personal apply <job_enc_id> --resume <enc_id> --message "自我介紹…" --confirm
154
+ wport personal apply <job_enc_id> --resume <enc_id> --message-file intro.txt --confirm # or - for stdin
155
+ wport personal apply --file batch.json --confirm # { "applications": [...] }, max 20, partial success
156
+
142
157
  # Sign out (revokes the refresh token server-side, clears local credentials)
143
158
  wport logout
144
159
  ```
@@ -309,6 +324,19 @@ Three codegen variants — pick whichever matches your setup:
309
324
 
310
325
  The `:prod` / `:remote` variants go through `scripts/gen-from-remote.cjs`, which filters the upstream spec down to the endpoints this CLI uses (it ignores unrelated `$ref` breakage elsewhere in the spec). When the CLI starts using a new endpoint, add it to `KEEP_PATHS` in that script.
311
326
 
327
+ #### 發 dev channel 到 npm
328
+
329
+ npm version prerelease --preid=dev --no-git-tag-version # 0.9.0 → 0.9.1-dev.0(prerelease 會先進 patch)
330
+ npm run build:dev # 選用:發布前先在本地確認 dev build 行為正常
331
+ # 發布必須帶 WPORT_BUILD_CHANNEL=dev:否則 npm publish 的 prepublishOnly hook 會跑 `npm run build`(prod),
332
+ # 把 dist 覆寫回 prod 產物,結果 @wport/cli@dev 底下發成 prod build(靜默、不報錯)。
333
+ WPORT_BUILD_CHANNEL=dev npm publish --tag dev --otp <code> # 必須顯式帶 --tag dev
334
+ npm view @wport/cli dist-tags # 斷言 latest 未被動到
335
+
336
+ ⚠️ npm 預設 tag 是 `latest`,即使版號是 prerelease;漏掉 `--tag dev` 會把 `latest` 指到 dev 版,污染所有 `npm i @wport/cli`。發布後務必用 `npm view dist-tags` 確認 `latest` 沒變。
337
+
338
+ ⚠️ `package.json` 的 `prepublishOnly` 會在 `npm publish` 時自動重跑 `npm run build`(prod build,無 dev channel),覆寫掉上一步 `build:dev` 產出的 dist。真正決定 channel 的是 publish 指令本身的 `WPORT_BUILD_CHANNEL=dev` 前綴 —— 該 env 會被 npm 傳進 prepublishOnly 的子行程,讓重跑的 `npm run build` 也讀到 dev channel。漏掉這個前綴,`@wport/cli@dev` 就會靜默發成連 `api.wport.me` 的 prod build。
339
+
312
340
  ## Support
313
341
 
314
342
  This CLI is in pre-release. Bug reports and feature requests are best-effort and handled directly by the maintainer.
package/dist/index.js CHANGED
@@ -179,7 +179,7 @@ function isTimeoutAbort(err) {
179
179
  return false;
180
180
  }
181
181
  function buildUserAgent() {
182
- return `wport-cli/${"0.8.0"} (node ${process.version}; ${process.platform})`;
182
+ return `wport-cli/${"0.9.1-dev.0"} (node ${process.version}; ${process.platform})`;
183
183
  }
184
184
  function unwrapDataResponse(body) {
185
185
  if (body && typeof body === "object" && "success" in body && "data" in body) {
@@ -366,8 +366,26 @@ function validateAndCoerce(key, value) {
366
366
  }
367
367
  }
368
368
 
369
+ // src/lib/channel.ts
370
+ var CHANNEL_BASE_URL = {
371
+ prod: "https://api.wport.me",
372
+ dev: "https://developers.wport.me/v2"
373
+ };
374
+ function currentChannel() {
375
+ return true ? "dev" : "prod";
376
+ }
377
+ function channelBaseUrl(channel) {
378
+ return CHANNEL_BASE_URL[channel] ?? CHANNEL_BASE_URL.prod;
379
+ }
380
+ function channelBanner() {
381
+ const channel = currentChannel();
382
+ if (channel === "prod") return null;
383
+ return `[${channel}] targeting the dev backend
384
+ `;
385
+ }
386
+
369
387
  // src/lib/global-opts.ts
370
- var DEFAULT_BASE_URL = "https://api.wport.me";
388
+ var DEFAULT_BASE_URL = channelBaseUrl(currentChannel());
371
389
  var API_BASE_ENV_VAR = "WPORT_API_BASE";
372
390
  var DEFAULT_LOCALE = "zh-TW";
373
391
  var DEFAULT_TIMEOUT_MS = 1e4;
@@ -981,7 +999,8 @@ var KEY_PREFIX = "wpk_live_";
981
999
  var KEY_MIN_LENGTH = KEY_PREFIX.length + 32;
982
1000
  var paths2 = (0, import_env_paths2.default)("wport", { suffix: "" });
983
1001
  function getCredentialsPath() {
984
- return (0, import_node_path2.join)(paths2.config, "credentials.json");
1002
+ const fileName = currentChannel() === "dev" ? "credentials-dev.json" : "credentials.json";
1003
+ return (0, import_node_path2.join)(paths2.config, fileName);
985
1004
  }
986
1005
  function isValidKeyFormat(key) {
987
1006
  return key.startsWith(KEY_PREFIX) && key.length >= KEY_MIN_LENGTH && !/\s/.test(key);
@@ -1148,6 +1167,7 @@ function ensureFormat(key, source) {
1148
1167
 
1149
1168
  // src/lib/personal-types.ts
1150
1169
  var PERSONAL_RESUMES_BASE = "/api/v1/personal/resumes";
1170
+ var PERSONAL_APPLICATIONS_BASE = "/api/v1/personal/applications";
1151
1171
  var OAUTH_BASE = "/api/oauth";
1152
1172
  var OAUTH_SESSIONS_BASE = `${OAUTH_BASE}/sessions`;
1153
1173
  var SECTION_WRITE_PLAN = {
@@ -1252,11 +1272,12 @@ function registerDoctorCommand(program2) {
1252
1272
  }
1253
1273
  async function runDoctor(ctx) {
1254
1274
  const line = (s = "") => process.stdout.write(s + "\n");
1255
- line(`wport-cli ${"0.8.0"}`);
1275
+ line(`wport-cli ${"0.9.1-dev.0"}`);
1256
1276
  line(` bundled schema fingerprint: ${"839e8a891dfb"}`);
1257
1277
  line("");
1258
1278
  line("Resolved configuration:");
1259
1279
  line(` API base URL: ${ctx.baseUrl}`);
1280
+ line(` channel: ${currentChannel()}`);
1260
1281
  line(` locale: ${ctx.locale}`);
1261
1282
  line(` timeout: ${ctx.timeoutMs}ms`);
1262
1283
  const cfgPath = getConfigPath();
@@ -1426,6 +1447,8 @@ function warnIfRateLimitLow(headers) {
1426
1447
 
1427
1448
  // src/commands/enterprise/login.ts
1428
1449
  async function performLogin(ctx, key) {
1450
+ const banner = channelBanner();
1451
+ if (banner) process.stderr.write(banner);
1429
1452
  if (!isValidKeyFormat(key)) {
1430
1453
  throw new CliError(
1431
1454
  `That does not look like a valid ${KEY_PREFIX} key. Nothing was saved.`,
@@ -2743,6 +2766,8 @@ function describeIdentity(displayName, email) {
2743
2766
  return "this device";
2744
2767
  }
2745
2768
  async function performLogin2(ctx, flags, deps = {}) {
2769
+ const banner = channelBanner();
2770
+ if (banner) process.stderr.write(banner);
2746
2771
  const openBrowser = deps.openBrowser ?? openInBrowser;
2747
2772
  const hostnameFn = deps.hostname ?? import_node_os.hostname;
2748
2773
  if (!flags.force) {
@@ -4542,15 +4567,170 @@ function registerPersonalResumesCommand(parent) {
4542
4567
  registerPersonalResumesDelete(resumes);
4543
4568
  }
4544
4569
 
4570
+ // src/commands/personal/apply/index.ts
4571
+ var import_node_crypto15 = require("crypto");
4572
+ var import_node_fs11 = require("fs");
4573
+
4574
+ // src/commands/personal/apply/message-input.ts
4575
+ var import_node_fs10 = require("fs");
4576
+ var MAX_MESSAGE = 2e3;
4577
+ function defaultReadStdin() {
4578
+ return (0, import_node_fs10.readFileSync)(0, "utf8");
4579
+ }
4580
+ function validateMessageText(raw) {
4581
+ const trimmed = raw.trim();
4582
+ if (trimmed.length < 1) {
4583
+ throw new CliError("application message must not be empty (1~2000 chars) \u61C9\u5FB5\u8A0A\u606F\u5FC5\u586B", ExitCode.InvalidArgument);
4584
+ }
4585
+ if (trimmed.length > MAX_MESSAGE) {
4586
+ throw new CliError(`application message exceeds ${MAX_MESSAGE} chars \u61C9\u5FB5\u8A0A\u606F\u4E0D\u53EF\u8D85\u904E 2000 \u5B57`, ExitCode.InvalidArgument);
4587
+ }
4588
+ return trimmed;
4589
+ }
4590
+ function resolveMessage(opts) {
4591
+ const hasMsg = opts.message !== void 0;
4592
+ const hasFile = opts.messageFile !== void 0;
4593
+ if (hasMsg && hasFile) {
4594
+ throw new CliError("--message and --message-file are mutually exclusive \u4E92\u65A5", ExitCode.InvalidArgument);
4595
+ }
4596
+ if (!hasMsg && !hasFile) {
4597
+ throw new CliError("--message or --message-file is required \u61C9\u5FB5\u8A0A\u606F\u5FC5\u586B", ExitCode.InvalidArgument);
4598
+ }
4599
+ if (hasMsg) return validateMessageText(opts.message);
4600
+ const file = opts.messageFile;
4601
+ const raw = file === "-" ? (opts.readStdin ?? defaultReadStdin)() : (0, import_node_fs10.readFileSync)(file, "utf8");
4602
+ return validateMessageText(raw);
4603
+ }
4604
+ function validateBatchItems(items) {
4605
+ if (!Array.isArray(items) || items.length < 1) {
4606
+ throw new CliError('batch file must contain a non-empty "applications" array', ExitCode.InvalidArgument);
4607
+ }
4608
+ if (items.length > 20) {
4609
+ throw new CliError(`batch exceeds 20 items (got ${items.length}) \u6279\u6B21\u4E0A\u9650 20 \u7B46`, ExitCode.InvalidArgument);
4610
+ }
4611
+ return items.map((it, i) => {
4612
+ const o = it;
4613
+ const jobId = typeof o?.enc_job_id === "string" ? o.enc_job_id.trim() : "";
4614
+ const resumeId = typeof o?.enc_resume_id === "string" ? o.enc_resume_id.trim() : "";
4615
+ if (!jobId || !resumeId) {
4616
+ throw new CliError(`batch item #${i}: enc_job_id and enc_resume_id required`, ExitCode.InvalidArgument);
4617
+ }
4618
+ const message = validateMessageText(typeof o?.application_message === "string" ? o.application_message : "");
4619
+ return { enc_job_id: jobId, enc_resume_id: resumeId, application_message: message };
4620
+ });
4621
+ }
4622
+
4623
+ // src/commands/personal/apply/index.ts
4624
+ function rethrowApplyError(err) {
4625
+ if (err instanceof ServerClientHttpError) {
4626
+ const body = err.body ?? null;
4627
+ const code = body?.error_code;
4628
+ if (err.status === 409 && code === "application_cooldown") {
4629
+ throw new CliError(`Application in cooldown. Next apply at: ${body?.next_apply_at ?? "unknown"}`, ExitCode.ServerClientError);
4630
+ }
4631
+ if (err.status === 409 && code === "job_not_available") {
4632
+ throw new CliError("Job is no longer available (closed or removed).", ExitCode.ServerClientError);
4633
+ }
4634
+ if (err.status === 429 && code === "apply_daily_limit_reached") {
4635
+ throw new CliError(
4636
+ `Daily application limit reached (${body?.used}/${body?.limit}). Resets at: ${body?.resets_at ?? "tomorrow"}`,
4637
+ ExitCode.ServerClientError
4638
+ );
4639
+ }
4640
+ }
4641
+ throw err;
4642
+ }
4643
+ async function runPersonalApply(ctx, args) {
4644
+ if (!args.confirm) {
4645
+ throw new CliError(
4646
+ "Refusing to apply without --confirm (application is visible to the employer)",
4647
+ ExitCode.InvalidArgument
4648
+ );
4649
+ }
4650
+ const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
4651
+ const body = { enc_job_id: args.encJobId, enc_resume_id: args.encResumeId, application_message: args.message };
4652
+ let result;
4653
+ try {
4654
+ result = await personalPost(opts, PERSONAL_APPLICATIONS_BASE, body, { idempotencyKey: (0, import_node_crypto15.randomUUID)() });
4655
+ } catch (err) {
4656
+ rethrowApplyError(err);
4657
+ }
4658
+ const data = result.body?.data ?? {};
4659
+ if (ctx.format === "json") {
4660
+ printJson(data);
4661
+ return;
4662
+ }
4663
+ process.stdout.write(`Applied. application: ${sanitizeForTerminal(String(data.enc_application_id ?? ""))}
4664
+ `);
4665
+ const q = data.quota;
4666
+ if (q) process.stdout.write(`Daily quota: ${q.used}/${q.limit} used, ${q.remaining} remaining
4667
+ `);
4668
+ }
4669
+ function registerPersonalApplyCommand(parent) {
4670
+ parent.command("apply [job_enc_id]").description("Apply to a job (single) or batch via --file (requires --confirm)").option("--resume <enc_id>", "resume enc_id to apply with (single mode)").option("--message <text>", "application message (1~2000 chars)").option("--message-file <path>", "read application message from file (use - for stdin)").option("--file <path>", 'batch JSON file { "applications": [...] } (max 20)').option("--confirm", "confirm this outward-facing action").action(async (jobEncId, flags, command) => {
4671
+ const ctx = resolveContext(command);
4672
+ if (flags.file) {
4673
+ await runPersonalApplyBatch(ctx, { file: flags.file, confirm: flags.confirm === true });
4674
+ return;
4675
+ }
4676
+ if (!jobEncId || !flags.resume) {
4677
+ throw new CliError("single apply requires <job_enc_id> and --resume", ExitCode.InvalidArgument);
4678
+ }
4679
+ const message = resolveMessage({ message: flags.message, messageFile: flags.messageFile });
4680
+ await runPersonalApply(ctx, {
4681
+ encJobId: jobEncId,
4682
+ encResumeId: flags.resume,
4683
+ message,
4684
+ confirm: flags.confirm === true
4685
+ });
4686
+ });
4687
+ }
4688
+ async function runPersonalApplyBatch(ctx, args) {
4689
+ if (!args.confirm) {
4690
+ throw new CliError(
4691
+ "Refusing to apply without --confirm (applications are visible to employers)",
4692
+ ExitCode.InvalidArgument
4693
+ );
4694
+ }
4695
+ let parsed;
4696
+ try {
4697
+ parsed = JSON.parse((0, import_node_fs11.readFileSync)(args.file, "utf8"));
4698
+ } catch {
4699
+ throw new CliError(`Cannot read/parse batch file: ${args.file}`, ExitCode.InvalidArgument);
4700
+ }
4701
+ const items = validateBatchItems(parsed?.applications);
4702
+ const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
4703
+ let result;
4704
+ try {
4705
+ result = await personalPost(opts, `${PERSONAL_APPLICATIONS_BASE}/batch`, { applications: items }, { idempotencyKey: (0, import_node_crypto15.randomUUID)() });
4706
+ } catch (err) {
4707
+ rethrowApplyError(err);
4708
+ }
4709
+ const data = result.body?.data ?? {};
4710
+ if (ctx.format === "json") {
4711
+ printJson(data);
4712
+ return;
4713
+ }
4714
+ const succeeded = data.succeeded ?? [];
4715
+ const failed = data.failed ?? [];
4716
+ process.stdout.write(`Batch applied: ${succeeded.length} succeeded, ${failed.length} failed
4717
+ `);
4718
+ for (const f of failed) {
4719
+ process.stdout.write(` #${f.index} ${sanitizeForTerminal(f.enc_job_id)}: ${sanitizeForTerminal(f.error_code)}
4720
+ `);
4721
+ }
4722
+ }
4723
+
4545
4724
  // src/commands/personal/index.ts
4546
4725
  function registerPersonalCommand(program2) {
4547
- const personal = program2.command("personal").description("Manage your personal resumes and profile");
4726
+ const personal = program2.command("personal").description("Manage your personal resumes, profile and applications");
4548
4727
  registerPersonalResumesCommand(personal);
4728
+ registerPersonalApplyCommand(personal);
4549
4729
  }
4550
4730
 
4551
4731
  // src/index.ts
4552
4732
  var program = new import_commander.Command();
4553
- program.name("wport").description("wport CLI \u2014 terminal interface to the W101 Talent Search Hub public API").version("0.8.0", "-v, --version", "output the CLI version").option("--lang <locale>", "Accept-Language locale: zh-TW | en-US | vi-VN | th-TH | id-ID").option("--api <url>", "override API base URL").option("--output <fmt>", "output format: table | json").option("--no-color", "disable color output").option("--timeout <ms>", "HTTP timeout in milliseconds", (v) => Number(v));
4733
+ program.name("wport").description("wport CLI \u2014 terminal interface to the W101 Talent Search Hub public API").version("0.9.1-dev.0", "-v, --version", "output the CLI version").option("--lang <locale>", "Accept-Language locale: zh-TW | en-US | vi-VN | th-TH | id-ID").option("--api <url>", "override API base URL").option("--output <fmt>", "output format: table | json").option("--no-color", "disable color output").option("--timeout <ms>", "HTTP timeout in milliseconds", (v) => Number(v));
4554
4734
  registerJobsCommand(program);
4555
4735
  registerConfigCommand(program);
4556
4736
  registerDoctorCommand(program);