@wport/cli 0.7.0 → 0.8.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 +46 -0
- package/README.md +56 -0
- package/dist/index.js +2192 -132
- package/dist/index.js.map +1 -1
- package/package.json +13 -4
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.
|
|
182
|
+
return `wport-cli/${"0.8.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) {
|
|
@@ -970,7 +970,269 @@ function registerConfigCommand(program2) {
|
|
|
970
970
|
}
|
|
971
971
|
|
|
972
972
|
// src/commands/doctor.ts
|
|
973
|
+
var import_node_fs6 = require("fs");
|
|
974
|
+
|
|
975
|
+
// src/lib/credentials-store.ts
|
|
973
976
|
var import_node_fs5 = require("fs");
|
|
977
|
+
var import_node_path2 = require("path");
|
|
978
|
+
var import_env_paths2 = __toESM(require("env-paths"));
|
|
979
|
+
var API_KEY_ENV_VAR = "WPORT_API_KEY";
|
|
980
|
+
var KEY_PREFIX = "wpk_live_";
|
|
981
|
+
var KEY_MIN_LENGTH = KEY_PREFIX.length + 32;
|
|
982
|
+
var paths2 = (0, import_env_paths2.default)("wport", { suffix: "" });
|
|
983
|
+
function getCredentialsPath() {
|
|
984
|
+
return (0, import_node_path2.join)(paths2.config, "credentials.json");
|
|
985
|
+
}
|
|
986
|
+
function isValidKeyFormat(key) {
|
|
987
|
+
return key.startsWith(KEY_PREFIX) && key.length >= KEY_MIN_LENGTH && !/\s/.test(key);
|
|
988
|
+
}
|
|
989
|
+
function maskKey(key) {
|
|
990
|
+
return `${KEY_PREFIX}\u2022\u2022\u2022\u2022${key.slice(-4)}`;
|
|
991
|
+
}
|
|
992
|
+
function readRawFile(strict) {
|
|
993
|
+
const path = getCredentialsPath();
|
|
994
|
+
if (!(0, import_node_fs5.existsSync)(path)) return {};
|
|
995
|
+
let parsed;
|
|
996
|
+
try {
|
|
997
|
+
parsed = JSON.parse((0, import_node_fs5.readFileSync)(path, "utf8"));
|
|
998
|
+
} catch (err) {
|
|
999
|
+
if (!strict) return {};
|
|
1000
|
+
throw new CliError(
|
|
1001
|
+
`Failed to read credentials at ${path}: ${err.message}. Run "wport enterprise login" to recreate it.`,
|
|
1002
|
+
ExitCode.ConfigCorrupt
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1006
|
+
if (!strict) return {};
|
|
1007
|
+
throw new CliError(
|
|
1008
|
+
`Credentials file at ${path} is malformed. Run "wport enterprise login" to recreate it.`,
|
|
1009
|
+
ExitCode.ConfigCorrupt
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
const raw = parsed;
|
|
1013
|
+
if (typeof raw.api_key === "string") {
|
|
1014
|
+
return { enterprise: raw };
|
|
1015
|
+
}
|
|
1016
|
+
return { enterprise: raw.enterprise, personal: raw.personal };
|
|
1017
|
+
}
|
|
1018
|
+
function parseEnterpriseCredentials(raw) {
|
|
1019
|
+
const path = getCredentialsPath();
|
|
1020
|
+
if (!raw || typeof raw !== "object" || typeof raw.api_key !== "string") {
|
|
1021
|
+
throw new CliError(
|
|
1022
|
+
`Credentials file at ${path} is malformed. Run "wport enterprise login" to recreate it.`,
|
|
1023
|
+
ExitCode.ConfigCorrupt
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
const r = raw;
|
|
1027
|
+
const apiKey = r.api_key;
|
|
1028
|
+
return {
|
|
1029
|
+
api_key: apiKey,
|
|
1030
|
+
company_name: typeof r.company_name === "string" ? r.company_name : "",
|
|
1031
|
+
key_last4: typeof r.key_last4 === "string" ? r.key_last4 : apiKey.slice(-4),
|
|
1032
|
+
saved_at: typeof r.saved_at === "string" ? r.saved_at : ""
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function parsePersonalCredentials(raw) {
|
|
1036
|
+
const path = getCredentialsPath();
|
|
1037
|
+
if (!raw || typeof raw !== "object" || typeof raw.access_token !== "string" || typeof raw.refresh_token !== "string" || typeof raw.expires_at !== "string") {
|
|
1038
|
+
throw new CliError(
|
|
1039
|
+
`Personal credentials at ${path} are malformed or incomplete. Run "wport login" to sign in again.`,
|
|
1040
|
+
ExitCode.ConfigCorrupt
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
const r = raw;
|
|
1044
|
+
return {
|
|
1045
|
+
access_token: r.access_token,
|
|
1046
|
+
refresh_token: r.refresh_token,
|
|
1047
|
+
expires_at: r.expires_at,
|
|
1048
|
+
display_name: typeof r.display_name === "string" ? r.display_name : "",
|
|
1049
|
+
email: typeof r.email === "string" ? r.email : "",
|
|
1050
|
+
session_created_at: typeof r.session_created_at === "string" ? r.session_created_at : ""
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
function writeFileAtomic0600(path, content) {
|
|
1054
|
+
(0, import_node_fs5.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
|
|
1055
|
+
const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
1056
|
+
const fd = (0, import_node_fs5.openSync)(tmpPath, "w", 384);
|
|
1057
|
+
try {
|
|
1058
|
+
(0, import_node_fs5.writeSync)(fd, content);
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
(0, import_node_fs5.closeSync)(fd);
|
|
1061
|
+
try {
|
|
1062
|
+
(0, import_node_fs5.unlinkSync)(tmpPath);
|
|
1063
|
+
} catch {
|
|
1064
|
+
}
|
|
1065
|
+
throw err;
|
|
1066
|
+
}
|
|
1067
|
+
(0, import_node_fs5.closeSync)(fd);
|
|
1068
|
+
if (process.platform !== "win32") {
|
|
1069
|
+
try {
|
|
1070
|
+
(0, import_node_fs5.chmodSync)(tmpPath, 384);
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
printWarn(
|
|
1073
|
+
`Failed to chmod 0600 on credentials tmpfile: ${err.message}. Other users on this system may be able to read your API key.`,
|
|
1074
|
+
false
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
(0, import_node_fs5.renameSync)(tmpPath, path);
|
|
1079
|
+
}
|
|
1080
|
+
function writeCredentialsFile(file) {
|
|
1081
|
+
const body = { version: 2 };
|
|
1082
|
+
if (file.enterprise !== void 0) body.enterprise = file.enterprise;
|
|
1083
|
+
if (file.personal !== void 0) body.personal = file.personal;
|
|
1084
|
+
writeFileAtomic0600(getCredentialsPath(), JSON.stringify(body, null, 2) + "\n");
|
|
1085
|
+
}
|
|
1086
|
+
function loadCredentials() {
|
|
1087
|
+
const { enterprise } = readRawFile(true);
|
|
1088
|
+
if (enterprise === void 0) return null;
|
|
1089
|
+
return parseEnterpriseCredentials(enterprise);
|
|
1090
|
+
}
|
|
1091
|
+
function saveCredentials(creds) {
|
|
1092
|
+
const { personal } = readRawFile(false);
|
|
1093
|
+
writeCredentialsFile({ enterprise: creds, personal });
|
|
1094
|
+
}
|
|
1095
|
+
function deleteCredentials() {
|
|
1096
|
+
const path = getCredentialsPath();
|
|
1097
|
+
if (!(0, import_node_fs5.existsSync)(path)) return false;
|
|
1098
|
+
const { enterprise, personal } = readRawFile(false);
|
|
1099
|
+
if (enterprise === void 0 && personal === void 0) {
|
|
1100
|
+
(0, import_node_fs5.unlinkSync)(path);
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
if (enterprise === void 0) return false;
|
|
1104
|
+
if (personal === void 0) {
|
|
1105
|
+
(0, import_node_fs5.unlinkSync)(path);
|
|
1106
|
+
} else {
|
|
1107
|
+
writeCredentialsFile({ personal });
|
|
1108
|
+
}
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
function loadPersonalCredentials() {
|
|
1112
|
+
const { personal } = readRawFile(true);
|
|
1113
|
+
if (personal === void 0) return null;
|
|
1114
|
+
return parsePersonalCredentials(personal);
|
|
1115
|
+
}
|
|
1116
|
+
function savePersonalCredentials(p) {
|
|
1117
|
+
const { enterprise } = readRawFile(false);
|
|
1118
|
+
writeCredentialsFile({ enterprise, personal: p });
|
|
1119
|
+
}
|
|
1120
|
+
function deletePersonalCredentials() {
|
|
1121
|
+
const { enterprise, personal } = readRawFile(false);
|
|
1122
|
+
if (personal === void 0) return false;
|
|
1123
|
+
writeCredentialsFile({ enterprise });
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
function resolveApiKey(flagValue) {
|
|
1127
|
+
if (flagValue !== void 0) {
|
|
1128
|
+
ensureFormat(flagValue, "--api-key");
|
|
1129
|
+
return { key: flagValue, source: "flag" };
|
|
1130
|
+
}
|
|
1131
|
+
const fromEnv = process.env[API_KEY_ENV_VAR]?.trim();
|
|
1132
|
+
if (fromEnv) {
|
|
1133
|
+
ensureFormat(fromEnv, `${API_KEY_ENV_VAR} env var`);
|
|
1134
|
+
return { key: fromEnv, source: "env" };
|
|
1135
|
+
}
|
|
1136
|
+
const creds = loadCredentials();
|
|
1137
|
+
if (creds) return { key: creds.api_key, source: "file" };
|
|
1138
|
+
throw new CliError(
|
|
1139
|
+
`No API key found. Run "wport enterprise login" or set the ${API_KEY_ENV_VAR} env var.`,
|
|
1140
|
+
ExitCode.InvalidArgument
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
function ensureFormat(key, source) {
|
|
1144
|
+
if (!isValidKeyFormat(key)) {
|
|
1145
|
+
throw new CliError(`API key from ${source} is not a valid ${KEY_PREFIX} key`, ExitCode.InvalidArgument);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// src/lib/personal-types.ts
|
|
1150
|
+
var PERSONAL_RESUMES_BASE = "/api/v1/personal/resumes";
|
|
1151
|
+
var OAUTH_BASE = "/api/oauth";
|
|
1152
|
+
var OAUTH_SESSIONS_BASE = `${OAUTH_BASE}/sessions`;
|
|
1153
|
+
var SECTION_WRITE_PLAN = {
|
|
1154
|
+
education: { kind: "per-item-post", path: "/education" },
|
|
1155
|
+
work_experience: { kind: "work-experience", path: "/work-experience" },
|
|
1156
|
+
certificate: { kind: "per-item-post", path: "/certificate" },
|
|
1157
|
+
language: { kind: "per-item-post", path: "/language" },
|
|
1158
|
+
professional_skills: { kind: "single-post", path: "/professional-skills" },
|
|
1159
|
+
autobiography: { kind: "single-post", path: "/autobiography" },
|
|
1160
|
+
job_condition: { kind: "single-post", path: "/job-condition" },
|
|
1161
|
+
portfolio_links: { kind: "bulk-put", path: "/portfolio-links" },
|
|
1162
|
+
background: { kind: "single-post", path: "/background" }
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// src/lib/oauth.ts
|
|
1166
|
+
var EXPIRED_MESSAGE = "The device code expired before authorization completed. Run `wport login` to try again.";
|
|
1167
|
+
async function oauthPost(opts, path, body) {
|
|
1168
|
+
const url = new URL(`${opts.baseUrl}${path}`);
|
|
1169
|
+
const request = new Request(url, {
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
headers: {
|
|
1172
|
+
"Accept-Language": opts.locale,
|
|
1173
|
+
"User-Agent": buildUserAgent(),
|
|
1174
|
+
Accept: "application/json",
|
|
1175
|
+
"Content-Type": "application/json"
|
|
1176
|
+
},
|
|
1177
|
+
body: JSON.stringify(body)
|
|
1178
|
+
});
|
|
1179
|
+
const res = await fetchWithTimeout(request, opts.timeoutMs);
|
|
1180
|
+
const respBody = await res.json().catch(() => null);
|
|
1181
|
+
return { status: res.status, body: respBody };
|
|
1182
|
+
}
|
|
1183
|
+
function oauthErrorCode(body) {
|
|
1184
|
+
if (body && typeof body === "object" && typeof body.error === "string") {
|
|
1185
|
+
return body.error;
|
|
1186
|
+
}
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
async function requestDeviceCode(opts, deviceName) {
|
|
1190
|
+
const body = {};
|
|
1191
|
+
if (deviceName) body.device_name = deviceName;
|
|
1192
|
+
const { status, body: respBody } = await oauthPost(opts, `${OAUTH_BASE}/device/code`, body);
|
|
1193
|
+
if (status < 200 || status >= 300) throwForHttpStatus(status, respBody);
|
|
1194
|
+
return respBody;
|
|
1195
|
+
}
|
|
1196
|
+
async function pollForToken(opts, device, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
1197
|
+
let intervalSec = device.interval;
|
|
1198
|
+
const deadline = Date.now() + device.expires_in * 1e3;
|
|
1199
|
+
for (; ; ) {
|
|
1200
|
+
const { status, body } = await oauthPost(opts, `${OAUTH_BASE}/token`, {
|
|
1201
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1202
|
+
device_code: device.device_code
|
|
1203
|
+
});
|
|
1204
|
+
if (status >= 200 && status < 300) return body;
|
|
1205
|
+
const code = oauthErrorCode(body);
|
|
1206
|
+
if (code === "slow_down") {
|
|
1207
|
+
intervalSec += 5;
|
|
1208
|
+
} else if (code === "access_denied") {
|
|
1209
|
+
throw new CliError("Authorization was denied on the device.", ExitCode.ServerClientError);
|
|
1210
|
+
} else if (code === "expired_token") {
|
|
1211
|
+
throw new CliError(EXPIRED_MESSAGE, ExitCode.ServerClientError);
|
|
1212
|
+
} else if (code !== "authorization_pending") {
|
|
1213
|
+
throwForHttpStatus(status, body);
|
|
1214
|
+
}
|
|
1215
|
+
if (Date.now() >= deadline) throw new CliError(EXPIRED_MESSAGE, ExitCode.ServerClientError);
|
|
1216
|
+
await sleep(intervalSec * 1e3);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
async function refreshAccessToken(opts, refreshToken) {
|
|
1220
|
+
const { status, body } = await oauthPost(opts, `${OAUTH_BASE}/token`, {
|
|
1221
|
+
grant_type: "refresh_token",
|
|
1222
|
+
refresh_token: refreshToken
|
|
1223
|
+
});
|
|
1224
|
+
if (status >= 200 && status < 300) return body;
|
|
1225
|
+
if (oauthErrorCode(body) === "invalid_grant") {
|
|
1226
|
+
throw new CliError("Your session is no longer valid. Run `wport login` to sign in again.", ExitCode.ServerClientError);
|
|
1227
|
+
}
|
|
1228
|
+
throwForHttpStatus(status, body);
|
|
1229
|
+
}
|
|
1230
|
+
async function revokeRefreshToken(opts, refreshToken) {
|
|
1231
|
+
const { status, body } = await oauthPost(opts, `${OAUTH_BASE}/revoke`, { token: refreshToken });
|
|
1232
|
+
if (status < 200 || status >= 300) throwForHttpStatus(status, body);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/commands/doctor.ts
|
|
974
1236
|
var SILENT_IGNORED_PARAMS = ["orderBy", "order"];
|
|
975
1237
|
var ENTERPRISE_TALENT_BOUNDARY_NOTES = [
|
|
976
1238
|
"`enterprise talents` covers your APPLIED pool only (list / view / respond).",
|
|
@@ -981,40 +1243,68 @@ var ENTERPRISE_TALENT_BOUNDARY_NOTES = [
|
|
|
981
1243
|
];
|
|
982
1244
|
function registerDoctorCommand(program2) {
|
|
983
1245
|
program2.command("doctor").description(
|
|
984
|
-
"Diagnose CLI setup: resolved config, server reachability, schema fingerprint, and known server quirks."
|
|
1246
|
+
"Diagnose CLI setup: resolved config, personal login state, server reachability, schema fingerprint, and known server quirks."
|
|
985
1247
|
).action(async (_opts, command) => {
|
|
986
1248
|
const ctx = resolveContext(command);
|
|
987
|
-
const
|
|
988
|
-
line(`wport-cli ${"0.7.0"}`);
|
|
989
|
-
line(` bundled schema fingerprint: ${"839e8a891dfb"}`);
|
|
990
|
-
line("");
|
|
991
|
-
line("Resolved configuration:");
|
|
992
|
-
line(` API base URL: ${ctx.baseUrl}`);
|
|
993
|
-
line(` locale: ${ctx.locale}`);
|
|
994
|
-
line(` timeout: ${ctx.timeoutMs}ms`);
|
|
995
|
-
const cfgPath = getConfigPath();
|
|
996
|
-
line(` config file: ${cfgPath}${(0, import_node_fs5.existsSync)(cfgPath) ? "" : " (not present)"}`);
|
|
997
|
-
line("");
|
|
998
|
-
line("Server connectivity:");
|
|
999
|
-
const reachable = await probeServer(ctx, line);
|
|
1000
|
-
line("");
|
|
1001
|
-
line("Known server behaviours (read this before scripting an agent):");
|
|
1002
|
-
line(
|
|
1003
|
-
` \u2022 Sort is server-controlled. These query params are silently ignored: ${SILENT_IGNORED_PARAMS.join(", ")}.`
|
|
1004
|
-
);
|
|
1005
|
-
line(" \u2022 jobs search sorts by publish date, or by relevance when --keyword is set.");
|
|
1006
|
-
line(" \u2022 jobs view --batch caps parallelism (default 5, max 20) to stay friendly to the API.");
|
|
1007
|
-
line("");
|
|
1008
|
-
line("Enterprise talent scope (Enterprise API Key):");
|
|
1009
|
-
for (const note of ENTERPRISE_TALENT_BOUNDARY_NOTES) line(` \u2022 ${note}`);
|
|
1010
|
-
line("");
|
|
1011
|
-
line("Schema drift:");
|
|
1012
|
-
line(" The fingerprint above identifies the OpenAPI contract this CLI was built against.");
|
|
1013
|
-
line(" Automated drift detection needs a server-side schema-version endpoint, which is");
|
|
1014
|
-
line(" not available yet \u2014 for now, compare fingerprints manually after a server release. [TODO]");
|
|
1249
|
+
const reachable = await runDoctor(ctx);
|
|
1015
1250
|
if (!reachable) process.exit(ExitCode.ServerOrNetworkError);
|
|
1016
1251
|
});
|
|
1017
1252
|
}
|
|
1253
|
+
async function runDoctor(ctx) {
|
|
1254
|
+
const line = (s = "") => process.stdout.write(s + "\n");
|
|
1255
|
+
line(`wport-cli ${"0.8.0"}`);
|
|
1256
|
+
line(` bundled schema fingerprint: ${"839e8a891dfb"}`);
|
|
1257
|
+
line("");
|
|
1258
|
+
line("Resolved configuration:");
|
|
1259
|
+
line(` API base URL: ${ctx.baseUrl}`);
|
|
1260
|
+
line(` locale: ${ctx.locale}`);
|
|
1261
|
+
line(` timeout: ${ctx.timeoutMs}ms`);
|
|
1262
|
+
const cfgPath = getConfigPath();
|
|
1263
|
+
line(` config file: ${cfgPath}${(0, import_node_fs6.existsSync)(cfgPath) ? "" : " (not present)"}`);
|
|
1264
|
+
line("");
|
|
1265
|
+
line("Personal account (OAuth):");
|
|
1266
|
+
for (const l of describePersonalLoginLines()) line(` ${l}`);
|
|
1267
|
+
line("");
|
|
1268
|
+
line("Server connectivity:");
|
|
1269
|
+
const reachable = await probeServer(ctx, line);
|
|
1270
|
+
await probeAuthServer(ctx, line);
|
|
1271
|
+
line("");
|
|
1272
|
+
line("Known server behaviours (read this before scripting an agent):");
|
|
1273
|
+
line(
|
|
1274
|
+
` \u2022 Sort is server-controlled. These query params are silently ignored: ${SILENT_IGNORED_PARAMS.join(", ")}.`
|
|
1275
|
+
);
|
|
1276
|
+
line(" \u2022 jobs search sorts by publish date, or by relevance when --keyword is set.");
|
|
1277
|
+
line(" \u2022 jobs view --batch caps parallelism (default 5, max 20) to stay friendly to the API.");
|
|
1278
|
+
line("");
|
|
1279
|
+
line("Enterprise talent scope (Enterprise API Key):");
|
|
1280
|
+
for (const note of ENTERPRISE_TALENT_BOUNDARY_NOTES) line(` \u2022 ${note}`);
|
|
1281
|
+
line("");
|
|
1282
|
+
line("Schema drift:");
|
|
1283
|
+
line(" The fingerprint above identifies the OpenAPI contract this CLI was built against.");
|
|
1284
|
+
line(" Automated drift detection needs a server-side schema-version endpoint, which is");
|
|
1285
|
+
line(" not available yet \u2014 for now, compare fingerprints manually after a server release. [TODO]");
|
|
1286
|
+
return reachable;
|
|
1287
|
+
}
|
|
1288
|
+
function describePersonalLoginLines() {
|
|
1289
|
+
try {
|
|
1290
|
+
const creds = loadPersonalCredentials();
|
|
1291
|
+
if (!creds) return ["not logged in (run `wport login` to sign in)"];
|
|
1292
|
+
const expiresAtMs = Date.parse(creds.expires_at);
|
|
1293
|
+
if (Number.isNaN(expiresAtMs)) {
|
|
1294
|
+
return [
|
|
1295
|
+
"logged in",
|
|
1296
|
+
`access token expiry is unreadable ("${creds.expires_at}") \u2014 try \`wport login --force\` to re-authenticate`
|
|
1297
|
+
];
|
|
1298
|
+
}
|
|
1299
|
+
const expired = expiresAtMs <= Date.now();
|
|
1300
|
+
return [
|
|
1301
|
+
"logged in",
|
|
1302
|
+
expired ? `access token expired at ${creds.expires_at} (refreshes automatically on next request)` : `access token valid until ${creds.expires_at}`
|
|
1303
|
+
];
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
return [`personal credentials file appears corrupted: ${err instanceof Error ? err.message : String(err)}`];
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1018
1308
|
async function probeServer(ctx, line) {
|
|
1019
1309
|
try {
|
|
1020
1310
|
const client = createApiClient({ baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs });
|
|
@@ -1030,6 +1320,16 @@ async function probeServer(ctx, line) {
|
|
|
1030
1320
|
return false;
|
|
1031
1321
|
}
|
|
1032
1322
|
}
|
|
1323
|
+
var DOCTOR_PROBE_DEVICE_NAME = "wport doctor (connectivity check)";
|
|
1324
|
+
async function probeAuthServer(ctx, line) {
|
|
1325
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
1326
|
+
try {
|
|
1327
|
+
await requestDeviceCode(opts, DOCTOR_PROBE_DEVICE_NAME);
|
|
1328
|
+
line(" \u2713 auth server reachable (device code endpoint)");
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
line(` ! auth server check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1033
1333
|
|
|
1034
1334
|
// src/lib/enterprise-client.ts
|
|
1035
1335
|
var ENTERPRISE_PREFIX = "/api/v1/enterprise";
|
|
@@ -1124,107 +1424,6 @@ function warnIfRateLimitLow(headers) {
|
|
|
1124
1424
|
}
|
|
1125
1425
|
}
|
|
1126
1426
|
|
|
1127
|
-
// src/lib/credentials-store.ts
|
|
1128
|
-
var import_node_fs6 = require("fs");
|
|
1129
|
-
var import_node_path2 = require("path");
|
|
1130
|
-
var import_env_paths2 = __toESM(require("env-paths"));
|
|
1131
|
-
var API_KEY_ENV_VAR = "WPORT_API_KEY";
|
|
1132
|
-
var KEY_PREFIX = "wpk_live_";
|
|
1133
|
-
var KEY_MIN_LENGTH = KEY_PREFIX.length + 32;
|
|
1134
|
-
var paths2 = (0, import_env_paths2.default)("wport", { suffix: "" });
|
|
1135
|
-
function getCredentialsPath() {
|
|
1136
|
-
return (0, import_node_path2.join)(paths2.config, "credentials.json");
|
|
1137
|
-
}
|
|
1138
|
-
function isValidKeyFormat(key) {
|
|
1139
|
-
return key.startsWith(KEY_PREFIX) && key.length >= KEY_MIN_LENGTH && !/\s/.test(key);
|
|
1140
|
-
}
|
|
1141
|
-
function maskKey(key) {
|
|
1142
|
-
return `${KEY_PREFIX}\u2022\u2022\u2022\u2022${key.slice(-4)}`;
|
|
1143
|
-
}
|
|
1144
|
-
function loadCredentials() {
|
|
1145
|
-
const path = getCredentialsPath();
|
|
1146
|
-
if (!(0, import_node_fs6.existsSync)(path)) return null;
|
|
1147
|
-
let parsed;
|
|
1148
|
-
try {
|
|
1149
|
-
parsed = JSON.parse((0, import_node_fs6.readFileSync)(path, "utf8"));
|
|
1150
|
-
} catch (err) {
|
|
1151
|
-
throw new CliError(
|
|
1152
|
-
`Failed to read credentials at ${path}: ${err.message}. Run "wport enterprise login" to recreate it.`,
|
|
1153
|
-
ExitCode.ConfigCorrupt
|
|
1154
|
-
);
|
|
1155
|
-
}
|
|
1156
|
-
if (!parsed || typeof parsed !== "object" || typeof parsed.api_key !== "string") {
|
|
1157
|
-
throw new CliError(
|
|
1158
|
-
`Credentials file at ${path} is malformed. Run "wport enterprise login" to recreate it.`,
|
|
1159
|
-
ExitCode.ConfigCorrupt
|
|
1160
|
-
);
|
|
1161
|
-
}
|
|
1162
|
-
const raw = parsed;
|
|
1163
|
-
const apiKey = raw.api_key;
|
|
1164
|
-
return {
|
|
1165
|
-
api_key: apiKey,
|
|
1166
|
-
company_name: typeof raw.company_name === "string" ? raw.company_name : "",
|
|
1167
|
-
key_last4: typeof raw.key_last4 === "string" ? raw.key_last4 : apiKey.slice(-4),
|
|
1168
|
-
saved_at: typeof raw.saved_at === "string" ? raw.saved_at : ""
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1171
|
-
function saveCredentials(creds) {
|
|
1172
|
-
const path = getCredentialsPath();
|
|
1173
|
-
(0, import_node_fs6.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
|
|
1174
|
-
const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
1175
|
-
const fd = (0, import_node_fs6.openSync)(tmpPath, "w", 384);
|
|
1176
|
-
try {
|
|
1177
|
-
(0, import_node_fs6.writeSync)(fd, JSON.stringify(creds, null, 2) + "\n");
|
|
1178
|
-
} catch (err) {
|
|
1179
|
-
(0, import_node_fs6.closeSync)(fd);
|
|
1180
|
-
try {
|
|
1181
|
-
(0, import_node_fs6.unlinkSync)(tmpPath);
|
|
1182
|
-
} catch {
|
|
1183
|
-
}
|
|
1184
|
-
throw err;
|
|
1185
|
-
}
|
|
1186
|
-
(0, import_node_fs6.closeSync)(fd);
|
|
1187
|
-
if (process.platform !== "win32") {
|
|
1188
|
-
try {
|
|
1189
|
-
(0, import_node_fs6.chmodSync)(tmpPath, 384);
|
|
1190
|
-
} catch (err) {
|
|
1191
|
-
printWarn(
|
|
1192
|
-
`Failed to chmod 0600 on credentials tmpfile: ${err.message}. Other users on this system may be able to read your API key.`,
|
|
1193
|
-
false
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
(0, import_node_fs6.renameSync)(tmpPath, path);
|
|
1198
|
-
}
|
|
1199
|
-
function deleteCredentials() {
|
|
1200
|
-
const path = getCredentialsPath();
|
|
1201
|
-
if (!(0, import_node_fs6.existsSync)(path)) return false;
|
|
1202
|
-
(0, import_node_fs6.unlinkSync)(path);
|
|
1203
|
-
return true;
|
|
1204
|
-
}
|
|
1205
|
-
function resolveApiKey(flagValue) {
|
|
1206
|
-
if (flagValue !== void 0) {
|
|
1207
|
-
ensureFormat(flagValue, "--api-key");
|
|
1208
|
-
return { key: flagValue, source: "flag" };
|
|
1209
|
-
}
|
|
1210
|
-
const fromEnv = process.env[API_KEY_ENV_VAR]?.trim();
|
|
1211
|
-
if (fromEnv) {
|
|
1212
|
-
ensureFormat(fromEnv, `${API_KEY_ENV_VAR} env var`);
|
|
1213
|
-
return { key: fromEnv, source: "env" };
|
|
1214
|
-
}
|
|
1215
|
-
const creds = loadCredentials();
|
|
1216
|
-
if (creds) return { key: creds.api_key, source: "file" };
|
|
1217
|
-
throw new CliError(
|
|
1218
|
-
`No API key found. Run "wport enterprise login" or set the ${API_KEY_ENV_VAR} env var.`,
|
|
1219
|
-
ExitCode.InvalidArgument
|
|
1220
|
-
);
|
|
1221
|
-
}
|
|
1222
|
-
function ensureFormat(key, source) {
|
|
1223
|
-
if (!isValidKeyFormat(key)) {
|
|
1224
|
-
throw new CliError(`API key from ${source} is not a valid ${KEY_PREFIX} key`, ExitCode.InvalidArgument);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
1427
|
// src/commands/enterprise/login.ts
|
|
1229
1428
|
async function performLogin(ctx, key) {
|
|
1230
1429
|
if (!isValidKeyFormat(key)) {
|
|
@@ -2493,13 +2692,1874 @@ function registerEnterpriseCommand(program2) {
|
|
|
2493
2692
|
registerEnterpriseCampaignsCommand(enterprise);
|
|
2494
2693
|
}
|
|
2495
2694
|
|
|
2695
|
+
// src/commands/auth/login.ts
|
|
2696
|
+
var import_node_os = require("os");
|
|
2697
|
+
|
|
2698
|
+
// src/lib/browser-open.ts
|
|
2699
|
+
var import_node_child_process = require("child_process");
|
|
2700
|
+
function isSafeToOpen(url, platform) {
|
|
2701
|
+
let parsed;
|
|
2702
|
+
try {
|
|
2703
|
+
parsed = new URL(url);
|
|
2704
|
+
} catch {
|
|
2705
|
+
return false;
|
|
2706
|
+
}
|
|
2707
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
2708
|
+
if (platform === "win32" && /[&|^<>%"]/.test(url)) return false;
|
|
2709
|
+
return true;
|
|
2710
|
+
}
|
|
2711
|
+
function commandFor(platform, url) {
|
|
2712
|
+
switch (platform) {
|
|
2713
|
+
case "darwin":
|
|
2714
|
+
return { command: "open", args: [url] };
|
|
2715
|
+
case "win32":
|
|
2716
|
+
return { command: "cmd", args: ["/c", "start", '""', url] };
|
|
2717
|
+
case "linux":
|
|
2718
|
+
return { command: "xdg-open", args: [url] };
|
|
2719
|
+
default:
|
|
2720
|
+
return null;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
function openInBrowser(url, spawnFn = import_node_child_process.spawn) {
|
|
2724
|
+
if (!isSafeToOpen(url, process.platform)) return false;
|
|
2725
|
+
const resolved = commandFor(process.platform, url);
|
|
2726
|
+
if (!resolved) return false;
|
|
2727
|
+
try {
|
|
2728
|
+
const child = spawnFn(resolved.command, resolved.args, { detached: true, stdio: "ignore" });
|
|
2729
|
+
child.on("error", () => {
|
|
2730
|
+
});
|
|
2731
|
+
child.unref();
|
|
2732
|
+
return true;
|
|
2733
|
+
} catch {
|
|
2734
|
+
return false;
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// src/commands/auth/login.ts
|
|
2739
|
+
function describeIdentity(displayName, email) {
|
|
2740
|
+
if (displayName && email) return `${displayName} (${email})`;
|
|
2741
|
+
if (displayName) return displayName;
|
|
2742
|
+
if (email) return email;
|
|
2743
|
+
return "this device";
|
|
2744
|
+
}
|
|
2745
|
+
async function performLogin2(ctx, flags, deps = {}) {
|
|
2746
|
+
const openBrowser = deps.openBrowser ?? openInBrowser;
|
|
2747
|
+
const hostnameFn = deps.hostname ?? import_node_os.hostname;
|
|
2748
|
+
if (!flags.force) {
|
|
2749
|
+
const existing = loadPersonalCredentials();
|
|
2750
|
+
if (existing) {
|
|
2751
|
+
process.stdout.write(`Already logged in as ${describeIdentity(existing.display_name, existing.email)}.
|
|
2752
|
+
`);
|
|
2753
|
+
process.stdout.write("Run `wport login --force` to sign in again.\n");
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
const oauthOpts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
2758
|
+
const device = await requestDeviceCode(oauthOpts, hostnameFn());
|
|
2759
|
+
process.stdout.write(`
|
|
2760
|
+
Enter this code when prompted:
|
|
2761
|
+
|
|
2762
|
+
${device.user_code}
|
|
2763
|
+
|
|
2764
|
+
`);
|
|
2765
|
+
process.stdout.write(`Visit: ${device.verification_uri}
|
|
2766
|
+
`);
|
|
2767
|
+
if (!flags.noBrowser && process.stdout.isTTY) {
|
|
2768
|
+
const opened = openBrowser(device.verification_uri_complete);
|
|
2769
|
+
if (!opened) {
|
|
2770
|
+
process.stdout.write(`Open this URL manually: ${device.verification_uri_complete}
|
|
2771
|
+
`);
|
|
2772
|
+
} else {
|
|
2773
|
+
process.stdout.write(`Opened in your browser. If nothing appeared, visit: ${device.verification_uri_complete}
|
|
2774
|
+
`);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
const tokens = await pollForToken(oauthOpts, device, deps.sleep);
|
|
2778
|
+
const now = Date.now();
|
|
2779
|
+
savePersonalCredentials({
|
|
2780
|
+
access_token: tokens.access_token,
|
|
2781
|
+
refresh_token: tokens.refresh_token,
|
|
2782
|
+
expires_at: new Date(now + tokens.expires_in * 1e3).toISOString(),
|
|
2783
|
+
display_name: "",
|
|
2784
|
+
email: "",
|
|
2785
|
+
session_created_at: new Date(now).toISOString()
|
|
2786
|
+
});
|
|
2787
|
+
process.stdout.write("Logged in.\n");
|
|
2788
|
+
}
|
|
2789
|
+
function registerLoginCommand(program2) {
|
|
2790
|
+
program2.command("login").description("Sign in to your wport personal account (OAuth device authorization flow)").option("--no-browser", "do not try to open a browser automatically").option("--force", "sign in again even if already logged in").action(async (opts, command) => {
|
|
2791
|
+
const ctx = resolveContext(command);
|
|
2792
|
+
await performLogin2(ctx, { noBrowser: opts.browser === false, force: opts.force });
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// src/lib/personal-client.ts
|
|
2797
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
2798
|
+
var NOT_LOGGED_IN_MESSAGE = "Not logged in. Run `wport login`.";
|
|
2799
|
+
async function ensureFreshCredentials(opts) {
|
|
2800
|
+
const creds = loadPersonalCredentials();
|
|
2801
|
+
if (!creds) throw new CliError(NOT_LOGGED_IN_MESSAGE, ExitCode.ServerClientError);
|
|
2802
|
+
const msUntilExpiry = Date.parse(creds.expires_at) - Date.now();
|
|
2803
|
+
if (msUntilExpiry >= EXPIRY_SKEW_MS) return creds;
|
|
2804
|
+
return refreshAndSave(opts, creds);
|
|
2805
|
+
}
|
|
2806
|
+
async function refreshAndSave(opts, creds) {
|
|
2807
|
+
const tokens = await refreshAccessToken(opts, creds.refresh_token);
|
|
2808
|
+
const updated = {
|
|
2809
|
+
...creds,
|
|
2810
|
+
access_token: tokens.access_token,
|
|
2811
|
+
refresh_token: tokens.refresh_token,
|
|
2812
|
+
expires_at: new Date(Date.now() + tokens.expires_in * 1e3).toISOString()
|
|
2813
|
+
};
|
|
2814
|
+
savePersonalCredentials(updated);
|
|
2815
|
+
return updated;
|
|
2816
|
+
}
|
|
2817
|
+
async function attemptRequest(opts, method, path, accessToken, body, query, extra) {
|
|
2818
|
+
const url = new URL(`${opts.baseUrl}${path}`);
|
|
2819
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
2820
|
+
if (v !== void 0) url.searchParams.set(k, String(v));
|
|
2821
|
+
}
|
|
2822
|
+
const headers = {
|
|
2823
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2824
|
+
"Accept-Language": opts.locale,
|
|
2825
|
+
"User-Agent": buildUserAgent(),
|
|
2826
|
+
Accept: "application/json"
|
|
2827
|
+
};
|
|
2828
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
2829
|
+
if (extra?.idempotencyKey) headers["Idempotency-Key"] = extra.idempotencyKey;
|
|
2830
|
+
const request = new Request(url, {
|
|
2831
|
+
method,
|
|
2832
|
+
headers,
|
|
2833
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2834
|
+
});
|
|
2835
|
+
const res = await fetchWithTimeout(request, opts.timeoutMs);
|
|
2836
|
+
const respBody = await res.json().catch(() => null);
|
|
2837
|
+
return { status: res.status, body: respBody, headers: res.headers };
|
|
2838
|
+
}
|
|
2839
|
+
async function personalRequest(opts, method, path, body, query, extra) {
|
|
2840
|
+
const creds = await ensureFreshCredentials(opts);
|
|
2841
|
+
let result = await attemptRequest(opts, method, path, creds.access_token, body, query, extra);
|
|
2842
|
+
if (result.status === 401) {
|
|
2843
|
+
const refreshed = await refreshAndSave(opts, creds);
|
|
2844
|
+
result = await attemptRequest(opts, method, path, refreshed.access_token, body, query, extra);
|
|
2845
|
+
}
|
|
2846
|
+
if (result.status < 200 || result.status >= 300) throwPersonalHttpError(result.status, result.body);
|
|
2847
|
+
warnIfRateLimitLow2(result.headers);
|
|
2848
|
+
return { body: result.body, headers: result.headers };
|
|
2849
|
+
}
|
|
2850
|
+
function personalGet(opts, path, query) {
|
|
2851
|
+
return personalRequest(opts, "GET", path, void 0, query, void 0);
|
|
2852
|
+
}
|
|
2853
|
+
function personalPost(opts, path, body, extra) {
|
|
2854
|
+
return personalRequest(opts, "POST", path, body, void 0, extra);
|
|
2855
|
+
}
|
|
2856
|
+
function personalPatch(opts, path, body, extra) {
|
|
2857
|
+
return personalRequest(opts, "PATCH", path, body, void 0, extra);
|
|
2858
|
+
}
|
|
2859
|
+
function personalPut(opts, path, body, extra) {
|
|
2860
|
+
return personalRequest(opts, "PUT", path, body, void 0, extra);
|
|
2861
|
+
}
|
|
2862
|
+
function personalDelete(opts, path, extra) {
|
|
2863
|
+
return personalRequest(opts, "DELETE", path, void 0, void 0, extra);
|
|
2864
|
+
}
|
|
2865
|
+
function throwPersonalHttpError(status, body) {
|
|
2866
|
+
if (status === 401) {
|
|
2867
|
+
const base = extractErrorMessage(body) ?? `HTTP ${status}`;
|
|
2868
|
+
throw new CliError(
|
|
2869
|
+
`${base} \u2014 Your session may have expired or the request was rejected. Run \`wport login\` to sign in again.`,
|
|
2870
|
+
ExitCode.ServerClientError
|
|
2871
|
+
);
|
|
2872
|
+
}
|
|
2873
|
+
throwForHttpStatus(status, body);
|
|
2874
|
+
}
|
|
2875
|
+
function warnIfRateLimitLow2(headers) {
|
|
2876
|
+
const remaining = Number(headers.get("x-ratelimit-remaining"));
|
|
2877
|
+
const limit = Number(headers.get("x-ratelimit-limit"));
|
|
2878
|
+
if (Number.isFinite(remaining) && Number.isFinite(limit) && limit > 0 && remaining / limit < 0.1) {
|
|
2879
|
+
printWarn(`Rate limit nearly exhausted: ${remaining}/${limit} requests remaining this window.`, false);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// src/commands/auth/whoami.ts
|
|
2884
|
+
var PLACEHOLDER = "\u2014";
|
|
2885
|
+
function display(value) {
|
|
2886
|
+
const clean = sanitizeForTerminal(value ?? "");
|
|
2887
|
+
return clean.length > 0 ? clean : PLACEHOLDER;
|
|
2888
|
+
}
|
|
2889
|
+
async function performWhoami(ctx) {
|
|
2890
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
2891
|
+
const { body } = await personalGet(opts, OAUTH_SESSIONS_BASE);
|
|
2892
|
+
const sessions = unwrapDataArray(body);
|
|
2893
|
+
const current = sessions.find((s) => s.is_current);
|
|
2894
|
+
const localLoginAt = loadPersonalCredentials()?.session_created_at ?? null;
|
|
2895
|
+
if (ctx.format === "json") {
|
|
2896
|
+
printJson({
|
|
2897
|
+
logged_in: true,
|
|
2898
|
+
device_name: current?.device_name ?? null,
|
|
2899
|
+
session_created_at: current?.created_at ?? null,
|
|
2900
|
+
last_used_at: current?.last_used_at ?? null,
|
|
2901
|
+
local_login_at: localLoginAt
|
|
2902
|
+
});
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
const lines = [
|
|
2906
|
+
"Logged in.",
|
|
2907
|
+
`device: ${display(current?.device_name)}`,
|
|
2908
|
+
`session created: ${display(current?.created_at)}`,
|
|
2909
|
+
`last used: ${display(current?.last_used_at)}`,
|
|
2910
|
+
`local login time: ${display(localLoginAt)}`
|
|
2911
|
+
];
|
|
2912
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2913
|
+
}
|
|
2914
|
+
function registerWhoamiCommand(program2) {
|
|
2915
|
+
program2.command("whoami").description("Show your current personal login session (contacts the server)").action(async (_flags, command) => {
|
|
2916
|
+
await performWhoami(resolveContext(command));
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// src/commands/auth/logout.ts
|
|
2921
|
+
async function performLogout(ctx) {
|
|
2922
|
+
const creds = loadPersonalCredentials();
|
|
2923
|
+
if (!creds) {
|
|
2924
|
+
process.stdout.write("Not logged in.\n");
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
2928
|
+
try {
|
|
2929
|
+
await revokeRefreshToken(opts, creds.refresh_token);
|
|
2930
|
+
} catch (err) {
|
|
2931
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2932
|
+
printWarn(`Failed to revoke the session on the server: ${message}. Removing local credentials anyway.`, ctx.color);
|
|
2933
|
+
}
|
|
2934
|
+
deletePersonalCredentials();
|
|
2935
|
+
process.stdout.write("Logged out.\n");
|
|
2936
|
+
}
|
|
2937
|
+
function registerLogoutCommand(program2) {
|
|
2938
|
+
program2.command("logout").description("Sign out of your wport personal account (revokes the refresh token and clears local credentials)").action(async (_flags, command) => {
|
|
2939
|
+
await performLogout(resolveContext(command));
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// src/commands/sessions/list.ts
|
|
2944
|
+
function formatDate5(value) {
|
|
2945
|
+
return value ? String(value).slice(0, 10) : "";
|
|
2946
|
+
}
|
|
2947
|
+
async function runSessionsList(ctx) {
|
|
2948
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
2949
|
+
const { body } = await personalGet(opts, OAUTH_SESSIONS_BASE);
|
|
2950
|
+
const sessions = unwrapDataArray(body);
|
|
2951
|
+
if (ctx.format === "json") {
|
|
2952
|
+
printJson(sessions);
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
printTable(
|
|
2956
|
+
sessions,
|
|
2957
|
+
[
|
|
2958
|
+
{ header: "ENC_ID", value: (r) => r.enc_id.slice(0, 14) },
|
|
2959
|
+
{ header: "DEVICE", value: (r) => r.device_name ?? "", maxWidth: 24 },
|
|
2960
|
+
{ header: "CREATED", value: (r) => formatDate5(r.created_at), maxWidth: 12 },
|
|
2961
|
+
{ header: "LAST_USED", value: (r) => formatDate5(r.last_used_at), maxWidth: 12 },
|
|
2962
|
+
{ header: "CURRENT", value: (r) => r.is_current ? "*" : "" }
|
|
2963
|
+
],
|
|
2964
|
+
ctx.color
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
function registerSessionsList(parent) {
|
|
2968
|
+
parent.command("list").description("List your active personal login sessions (devices)").action(async (_flags, command) => {
|
|
2969
|
+
await runSessionsList(resolveContext(command));
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// src/commands/sessions/revoke.ts
|
|
2974
|
+
async function runSessionsRevoke(ctx, encId, allOthers) {
|
|
2975
|
+
const hasEncId = encId !== void 0;
|
|
2976
|
+
if (hasEncId === allOthers) {
|
|
2977
|
+
throw new CliError("Provide exactly one of <enc_id> or --all-others", ExitCode.InvalidArgument);
|
|
2978
|
+
}
|
|
2979
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
2980
|
+
if (allOthers) {
|
|
2981
|
+
const { body } = await personalDelete(opts, `${OAUTH_SESSIONS_BASE}/others`);
|
|
2982
|
+
const result = unwrapDataResponse(body);
|
|
2983
|
+
if (ctx.format === "json") {
|
|
2984
|
+
printJson(result);
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
process.stdout.write(`Revoked ${result.revoked_count} other session(s).
|
|
2988
|
+
`);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
const trimmed = encId.trim();
|
|
2992
|
+
if (!trimmed) throw new CliError("enc_id must not be empty", ExitCode.InvalidArgument);
|
|
2993
|
+
await personalDelete(opts, `${OAUTH_SESSIONS_BASE}/${encodeURIComponent(trimmed)}`);
|
|
2994
|
+
if (ctx.format === "json") {
|
|
2995
|
+
printJson({ enc_id: trimmed, revoked: true });
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
process.stdout.write(`Revoked session ${trimmed}.
|
|
2999
|
+
`);
|
|
3000
|
+
}
|
|
3001
|
+
function registerSessionsRevoke(parent) {
|
|
3002
|
+
parent.command("revoke [enc_id]").description("Revoke a session by enc_id, or every other session with --all-others").option("--all-others", "revoke every session except the current one").action(async (encId, flags, command) => {
|
|
3003
|
+
const ctx = resolveContext(command);
|
|
3004
|
+
await runSessionsRevoke(ctx, encId, flags.allOthers === true);
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// src/commands/sessions/index.ts
|
|
3009
|
+
function registerSessionsCommand(program2) {
|
|
3010
|
+
const sessions = program2.command("sessions").description("List and revoke your personal login sessions (devices)");
|
|
3011
|
+
registerSessionsList(sessions);
|
|
3012
|
+
registerSessionsRevoke(sessions);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/lib/resume-schema/definition.ts
|
|
3016
|
+
var DEGREE_TYPE_VALUES = ["phd", "master", "bachelor", "associate", "senior_high", "junior_or_below"];
|
|
3017
|
+
var EDUCATION_STATUS_VALUES = ["graduated", "dropped", "studying"];
|
|
3018
|
+
var JOB_FEATURE_CODE_VALUES = ["dispatch", "executive", "full_time", "internship", "part_time"];
|
|
3019
|
+
var WORKING_HOUR_TYPE_VALUES = ["day_shift", "evening_shift", "night_shift", "rotating_shift", "weekend_shift"];
|
|
3020
|
+
var AVAILABLE_START_TYPE_VALUES = ["available_after_hired", "available_custom_date"];
|
|
3021
|
+
var AVAILABLE_START_PERIOD_VALUES = ["week", "two_weeks", "month", "two_months", "three_months", "anytime"];
|
|
3022
|
+
var SALARY_EXPECTATION_TYPE_VALUES = ["negotiable", "company_rules", "custom"];
|
|
3023
|
+
var SALARY_UNIT_VALUES = ["hourly", "daily", "monthly", "yearly"];
|
|
3024
|
+
var PROFICIENCY_LEVEL_VALUES = ["beginner", "daily_convo", "proficient", "native"];
|
|
3025
|
+
var JOB_STATUS_VALUES = ["employed", "military", "student", "unemployed"];
|
|
3026
|
+
var VEHICLE_OR_LICENSE_TYPE_VALUES = [
|
|
3027
|
+
"bus",
|
|
3028
|
+
"heavy_motorcycle",
|
|
3029
|
+
"light_motorcycle",
|
|
3030
|
+
"light_car",
|
|
3031
|
+
"pro_bus",
|
|
3032
|
+
"pro_small_car",
|
|
3033
|
+
"pro_trailer",
|
|
3034
|
+
"pro_truck",
|
|
3035
|
+
"scooter",
|
|
3036
|
+
"trailer",
|
|
3037
|
+
"truck"
|
|
3038
|
+
];
|
|
3039
|
+
var CURRENT_YEAR = (/* @__PURE__ */ new Date()).getFullYear();
|
|
3040
|
+
var TOP_LEVEL_FIELDS = {
|
|
3041
|
+
name: {
|
|
3042
|
+
type: "string",
|
|
3043
|
+
required: true,
|
|
3044
|
+
minLength: 1,
|
|
3045
|
+
maxLength: 100,
|
|
3046
|
+
description: "\u5C65\u6B77\u540D\u7A31",
|
|
3047
|
+
example: "\u6211\u7684\u5C65\u6B77\u540D\u7A31"
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
var educationSection = {
|
|
3051
|
+
key: "education",
|
|
3052
|
+
kind: "array",
|
|
3053
|
+
description: "\u5B78\u6B77\uFF0C\u53EF\u591A\u7B46",
|
|
3054
|
+
fields: {
|
|
3055
|
+
school_name: {
|
|
3056
|
+
type: "string",
|
|
3057
|
+
required: true,
|
|
3058
|
+
minLength: 1,
|
|
3059
|
+
maxLength: 80,
|
|
3060
|
+
description: "\u5B78\u6821\u540D\u7A31",
|
|
3061
|
+
example: "National Taiwan University"
|
|
3062
|
+
},
|
|
3063
|
+
degree_code: {
|
|
3064
|
+
type: "enum",
|
|
3065
|
+
required: true,
|
|
3066
|
+
enumValues: DEGREE_TYPE_VALUES,
|
|
3067
|
+
description: "\u5B78\u6B77\u7B49\u7D1A",
|
|
3068
|
+
example: "bachelor"
|
|
3069
|
+
},
|
|
3070
|
+
department: {
|
|
3071
|
+
type: "string",
|
|
3072
|
+
required: true,
|
|
3073
|
+
minLength: 1,
|
|
3074
|
+
maxLength: 80,
|
|
3075
|
+
description: "\u4E3B\u4FEE\u79D1\u7CFB\u540D\u7A31",
|
|
3076
|
+
example: "Computer Science"
|
|
3077
|
+
},
|
|
3078
|
+
minor_department: {
|
|
3079
|
+
type: "string",
|
|
3080
|
+
required: false,
|
|
3081
|
+
minLength: 1,
|
|
3082
|
+
maxLength: 80,
|
|
3083
|
+
description: "\u526F\u4FEE\u79D1\u7CFB\u540D\u7A31",
|
|
3084
|
+
example: "Mathematics"
|
|
3085
|
+
},
|
|
3086
|
+
department_class_code: {
|
|
3087
|
+
type: "string",
|
|
3088
|
+
required: false,
|
|
3089
|
+
description: "\u4E3B\u4FEE\u79D1\u7CFB\u985E\u5225\u4EE3\u78BC",
|
|
3090
|
+
example: "engineering"
|
|
3091
|
+
},
|
|
3092
|
+
minor_department_class_code: {
|
|
3093
|
+
type: "string",
|
|
3094
|
+
required: false,
|
|
3095
|
+
description: "\u526F\u4FEE\u79D1\u7CFB\u985E\u5225\u4EE3\u78BC",
|
|
3096
|
+
example: "industry_machinery"
|
|
3097
|
+
},
|
|
3098
|
+
edu_status_code: {
|
|
3099
|
+
type: "enum",
|
|
3100
|
+
required: true,
|
|
3101
|
+
enumValues: EDUCATION_STATUS_VALUES,
|
|
3102
|
+
description: "\u5C31\u5B78\u72C0\u614B",
|
|
3103
|
+
example: "studying"
|
|
3104
|
+
},
|
|
3105
|
+
start_year: {
|
|
3106
|
+
type: "integer",
|
|
3107
|
+
required: true,
|
|
3108
|
+
min: 1900,
|
|
3109
|
+
max: CURRENT_YEAR,
|
|
3110
|
+
description: "\u5165\u5B78\u5E74\u4EFD",
|
|
3111
|
+
example: 2015
|
|
3112
|
+
},
|
|
3113
|
+
start_month: {
|
|
3114
|
+
type: "integer",
|
|
3115
|
+
required: true,
|
|
3116
|
+
min: 1,
|
|
3117
|
+
max: 12,
|
|
3118
|
+
description: "\u5165\u5B78\u6708\u4EFD",
|
|
3119
|
+
example: 9
|
|
3120
|
+
},
|
|
3121
|
+
end_year: {
|
|
3122
|
+
type: "integer",
|
|
3123
|
+
required: false,
|
|
3124
|
+
min: 1900,
|
|
3125
|
+
max: CURRENT_YEAR,
|
|
3126
|
+
description: "\u7562\u696D\u5E74\u4EFD\uFF08edu_status_code \u70BA graduated/dropped \u6642\u5FC5\u586B\uFF09",
|
|
3127
|
+
example: 2019
|
|
3128
|
+
},
|
|
3129
|
+
end_month: {
|
|
3130
|
+
type: "integer",
|
|
3131
|
+
required: false,
|
|
3132
|
+
min: 1,
|
|
3133
|
+
max: 12,
|
|
3134
|
+
description: "\u7562\u696D\u6708\u4EFD\uFF08edu_status_code \u70BA graduated/dropped \u6642\u5FC5\u586B\uFF09",
|
|
3135
|
+
example: 6
|
|
3136
|
+
},
|
|
3137
|
+
experience: {
|
|
3138
|
+
type: "string",
|
|
3139
|
+
required: false,
|
|
3140
|
+
htmlText: true,
|
|
3141
|
+
maxLength: 2e3,
|
|
3142
|
+
description: "\u5728\u6821\u7D93\u6B77\uFF08\u5BCC\u6587\u672C\uFF09",
|
|
3143
|
+
example: "<p>Served as the president of the student council.</p>"
|
|
3144
|
+
}
|
|
3145
|
+
},
|
|
3146
|
+
crossFieldRules: [
|
|
3147
|
+
{
|
|
3148
|
+
name: "education-graduation-date",
|
|
3149
|
+
message: 'When edu_status_code is "graduated" or "dropped", end_year/end_month are required and the end date must be after start_year/start_month and not later than today.',
|
|
3150
|
+
check(item) {
|
|
3151
|
+
const status = item.edu_status_code;
|
|
3152
|
+
if (status !== "graduated" && status !== "dropped") return true;
|
|
3153
|
+
const { end_year, end_month, start_year, start_month } = item;
|
|
3154
|
+
if (typeof end_year !== "number" || typeof end_month !== "number") return false;
|
|
3155
|
+
if (typeof start_year !== "number" || typeof start_month !== "number") return true;
|
|
3156
|
+
const start = new Date(start_year, start_month - 1);
|
|
3157
|
+
const end = new Date(end_year, end_month - 1);
|
|
3158
|
+
return end > start && end <= /* @__PURE__ */ new Date();
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
]
|
|
3162
|
+
};
|
|
3163
|
+
var workExperienceSection = {
|
|
3164
|
+
key: "work_experience",
|
|
3165
|
+
kind: "wrapper",
|
|
3166
|
+
description: "\u5DE5\u4F5C\u7D93\u9A57\uFF08\u5305\u88DD\u5C64\u542B\u300C\u7121\u5DE5\u4F5C\u7D93\u9A57\u300D\u65D7\u6A19\uFF09",
|
|
3167
|
+
wrapperFields: {
|
|
3168
|
+
has_no_work_experience: {
|
|
3169
|
+
type: "boolean",
|
|
3170
|
+
required: false,
|
|
3171
|
+
description: "\u662F\u5426\u7121\u5DE5\u4F5C\u7D93\u9A57\uFF08true \u6642 items \u5FC5\u70BA\u7A7A\u9663\u5217\uFF09",
|
|
3172
|
+
example: false
|
|
3173
|
+
}
|
|
3174
|
+
},
|
|
3175
|
+
fields: {
|
|
3176
|
+
job_title: {
|
|
3177
|
+
type: "string",
|
|
3178
|
+
required: true,
|
|
3179
|
+
maxLength: 100,
|
|
3180
|
+
description: "\u8077\u7A31",
|
|
3181
|
+
example: "Software Engineer"
|
|
3182
|
+
},
|
|
3183
|
+
job_type: {
|
|
3184
|
+
type: "enum",
|
|
3185
|
+
required: true,
|
|
3186
|
+
enumValues: JOB_FEATURE_CODE_VALUES,
|
|
3187
|
+
description: "\u8077\u52D9\u985E\u578B",
|
|
3188
|
+
example: "full_time"
|
|
3189
|
+
},
|
|
3190
|
+
company_name: {
|
|
3191
|
+
type: "string",
|
|
3192
|
+
required: true,
|
|
3193
|
+
maxLength: 200,
|
|
3194
|
+
description: "\u516C\u53F8\u540D\u7A31",
|
|
3195
|
+
example: "Tech Corp"
|
|
3196
|
+
},
|
|
3197
|
+
start_year: {
|
|
3198
|
+
type: "integer",
|
|
3199
|
+
required: true,
|
|
3200
|
+
min: 1900,
|
|
3201
|
+
max: CURRENT_YEAR,
|
|
3202
|
+
description: "\u4EFB\u8077\u958B\u59CB\u5E74\u4EFD",
|
|
3203
|
+
example: 2020
|
|
3204
|
+
},
|
|
3205
|
+
start_month: {
|
|
3206
|
+
type: "integer",
|
|
3207
|
+
required: true,
|
|
3208
|
+
min: 1,
|
|
3209
|
+
max: 12,
|
|
3210
|
+
description: "\u4EFB\u8077\u958B\u59CB\u6708\u4EFD",
|
|
3211
|
+
example: 1
|
|
3212
|
+
},
|
|
3213
|
+
end_year: {
|
|
3214
|
+
type: "integer",
|
|
3215
|
+
required: false,
|
|
3216
|
+
min: 1900,
|
|
3217
|
+
max: CURRENT_YEAR,
|
|
3218
|
+
description: "\u4EFB\u8077\u7D50\u675F\u5E74\u4EFD\uFF08is_current \u70BA false \u6642\u5FC5\u586B\uFF09",
|
|
3219
|
+
example: 2022
|
|
3220
|
+
},
|
|
3221
|
+
end_month: {
|
|
3222
|
+
type: "integer",
|
|
3223
|
+
required: false,
|
|
3224
|
+
min: 1,
|
|
3225
|
+
max: 12,
|
|
3226
|
+
description: "\u4EFB\u8077\u7D50\u675F\u6708\u4EFD\uFF08is_current \u70BA false \u6642\u5FC5\u586B\uFF09",
|
|
3227
|
+
example: 12
|
|
3228
|
+
},
|
|
3229
|
+
is_current: {
|
|
3230
|
+
type: "boolean",
|
|
3231
|
+
required: true,
|
|
3232
|
+
description: "\u662F\u5426\u5728\u8077\u4E2D\uFF08\u8B80\u5BEB\u4E0D\u5C0D\u7A31\uFF1A\u805A\u5408\u683C\u5F0F\u53D6 read shape \u70BA boolean\uFF0Corchestrator \u5BEB\u5165\u6642\u8F49 0/1\uFF09",
|
|
3233
|
+
example: false
|
|
3234
|
+
},
|
|
3235
|
+
job_description: {
|
|
3236
|
+
type: "string",
|
|
3237
|
+
required: false,
|
|
3238
|
+
htmlText: true,
|
|
3239
|
+
maxLength: 1e4,
|
|
3240
|
+
description: "\u5DE5\u4F5C\u5167\u5BB9\u63CF\u8FF0\uFF08\u5BCC\u6587\u672C\uFF09",
|
|
3241
|
+
example: "<p>Built and maintained internal developer tools.</p>"
|
|
3242
|
+
}
|
|
3243
|
+
},
|
|
3244
|
+
crossFieldRules: [
|
|
3245
|
+
{
|
|
3246
|
+
name: "work-experience-end-date",
|
|
3247
|
+
message: "When is_current is true, end_year/end_month must not be present. When is_current is false, end_year/end_month are required and the end date must be on/after the start date and not later than today.",
|
|
3248
|
+
check(item) {
|
|
3249
|
+
if (typeof item.is_current !== "boolean") return true;
|
|
3250
|
+
if (item.is_current) {
|
|
3251
|
+
return item.end_year === void 0 && item.end_month === void 0;
|
|
3252
|
+
}
|
|
3253
|
+
const { end_year, end_month, start_year, start_month } = item;
|
|
3254
|
+
if (typeof end_year !== "number" || typeof end_month !== "number") return false;
|
|
3255
|
+
if (typeof start_year !== "number" || typeof start_month !== "number") return true;
|
|
3256
|
+
const start = new Date(start_year, start_month - 1);
|
|
3257
|
+
const end = new Date(end_year, end_month - 1);
|
|
3258
|
+
return end >= start && end <= /* @__PURE__ */ new Date();
|
|
3259
|
+
}
|
|
3260
|
+
},
|
|
3261
|
+
{
|
|
3262
|
+
name: "work-experience-no-experience-items-empty",
|
|
3263
|
+
message: "When has_no_work_experience is true, items must be an empty array.",
|
|
3264
|
+
check(wrapper) {
|
|
3265
|
+
if (wrapper.has_no_work_experience !== true) return true;
|
|
3266
|
+
return Array.isArray(wrapper.items) && wrapper.items.length === 0;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
]
|
|
3270
|
+
};
|
|
3271
|
+
var certificateSection = {
|
|
3272
|
+
key: "certificate",
|
|
3273
|
+
kind: "array",
|
|
3274
|
+
description: "\u8B49\u7167\uFF0C\u53EF\u591A\u7B46",
|
|
3275
|
+
fields: {
|
|
3276
|
+
certificate_code: {
|
|
3277
|
+
type: "string",
|
|
3278
|
+
required: true,
|
|
3279
|
+
description: "\u8B49\u7167\u4EE3\u78BC",
|
|
3280
|
+
example: "4001001014"
|
|
3281
|
+
},
|
|
3282
|
+
issue_year: {
|
|
3283
|
+
type: "integer",
|
|
3284
|
+
required: true,
|
|
3285
|
+
min: 1900,
|
|
3286
|
+
max: 2100,
|
|
3287
|
+
description: "\u8D77\u59CB\u5E74\u4EFD\uFF08\u683C\u5F0F\uFF1AYYYY\uFF09",
|
|
3288
|
+
example: 2023
|
|
3289
|
+
},
|
|
3290
|
+
issue_month: {
|
|
3291
|
+
type: "integer",
|
|
3292
|
+
required: true,
|
|
3293
|
+
min: 1,
|
|
3294
|
+
max: 12,
|
|
3295
|
+
description: "\u8D77\u59CB\u6708\u4EFD\uFF08\u683C\u5F0F\uFF1AMM\uFF09",
|
|
3296
|
+
example: 1
|
|
3297
|
+
},
|
|
3298
|
+
expiry_year: {
|
|
3299
|
+
type: "integer",
|
|
3300
|
+
required: false,
|
|
3301
|
+
min: 1900,
|
|
3302
|
+
max: 2100,
|
|
3303
|
+
description: "\u5230\u671F\u5E74\u4EFD\uFF08is_permanent \u70BA false \u6642\u5FC5\u586B\uFF09",
|
|
3304
|
+
example: 2025
|
|
3305
|
+
},
|
|
3306
|
+
expiry_month: {
|
|
3307
|
+
type: "integer",
|
|
3308
|
+
required: false,
|
|
3309
|
+
min: 1,
|
|
3310
|
+
max: 12,
|
|
3311
|
+
description: "\u5230\u671F\u6708\u4EFD\uFF08is_permanent \u70BA false \u6642\u5FC5\u586B\uFF09",
|
|
3312
|
+
example: 1
|
|
3313
|
+
},
|
|
3314
|
+
is_permanent: {
|
|
3315
|
+
type: "boolean",
|
|
3316
|
+
required: true,
|
|
3317
|
+
description: "\u662F\u5426\u6C38\u4E45\u6709\u6548",
|
|
3318
|
+
example: false
|
|
3319
|
+
}
|
|
3320
|
+
},
|
|
3321
|
+
crossFieldRules: [
|
|
3322
|
+
{
|
|
3323
|
+
name: "certificate-expiry-required",
|
|
3324
|
+
message: "When is_permanent is false, expiry_year/expiry_month are required and must not be earlier than issue_year/issue_month.",
|
|
3325
|
+
check(item) {
|
|
3326
|
+
if (item.is_permanent !== false) return true;
|
|
3327
|
+
const { expiry_year, expiry_month, issue_year, issue_month } = item;
|
|
3328
|
+
if (typeof expiry_year !== "number" || typeof expiry_month !== "number") return false;
|
|
3329
|
+
if (typeof issue_year !== "number" || typeof issue_month !== "number") return true;
|
|
3330
|
+
return expiry_year * 12 + expiry_month >= issue_year * 12 + issue_month;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
]
|
|
3334
|
+
};
|
|
3335
|
+
var languageSection = {
|
|
3336
|
+
key: "language",
|
|
3337
|
+
kind: "array",
|
|
3338
|
+
description: "\u8A9E\u8A00\u80FD\u529B\uFF0C\u53EF\u591A\u7B46",
|
|
3339
|
+
fields: {
|
|
3340
|
+
code: {
|
|
3341
|
+
type: "string",
|
|
3342
|
+
required: true,
|
|
3343
|
+
description: "\u6280\u80FD\u8A9E\u8A00\u4EE3\u78BC\uFF08\u5C0D\u61C9 skill_languages \u8CC7\u6599\u8868\u4E2D\u7684 code\uFF09",
|
|
3344
|
+
example: "Chinese"
|
|
3345
|
+
},
|
|
3346
|
+
proficiency_level_code: {
|
|
3347
|
+
type: "enum",
|
|
3348
|
+
required: true,
|
|
3349
|
+
enumValues: PROFICIENCY_LEVEL_VALUES,
|
|
3350
|
+
description: "\u719F\u7DF4\u7A0B\u5EA6",
|
|
3351
|
+
example: "proficient"
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
};
|
|
3355
|
+
var professionalSkillsSection = {
|
|
3356
|
+
key: "professional_skills",
|
|
3357
|
+
kind: "object",
|
|
3358
|
+
description: "\u5C08\u696D\u6280\u80FD",
|
|
3359
|
+
fields: {
|
|
3360
|
+
tech_tool_codes: {
|
|
3361
|
+
type: "string[]",
|
|
3362
|
+
required: true,
|
|
3363
|
+
minItems: 1,
|
|
3364
|
+
maxItems: 10,
|
|
3365
|
+
description: "\u5DE5\u5177\u4EE3\u78BC\u9663\u5217\uFF08\u6700\u591A 10 \u500B\uFF09",
|
|
3366
|
+
example: ["12001001045", "12001001044"]
|
|
3367
|
+
},
|
|
3368
|
+
other_tool_description: {
|
|
3369
|
+
type: "string",
|
|
3370
|
+
required: false,
|
|
3371
|
+
htmlText: true,
|
|
3372
|
+
maxLength: 2e3,
|
|
3373
|
+
description: "\u5176\u4ED6\u64C5\u9577\u5DE5\u5177\u63CF\u8FF0\uFF08\u5BCC\u6587\u672C\uFF0C\u5BE6\u969B\u6587\u5B57\u4E0A\u9650 2000 \u5B57\uFF09",
|
|
3374
|
+
example: "\u719F\u6089 Photoshop \u548C Illustrator"
|
|
3375
|
+
},
|
|
3376
|
+
job_skill_codes: {
|
|
3377
|
+
type: "string[]",
|
|
3378
|
+
required: true,
|
|
3379
|
+
minItems: 1,
|
|
3380
|
+
maxItems: 10,
|
|
3381
|
+
description: "\u6280\u80FD\u4EE3\u78BC\u9663\u5217\uFF08\u6700\u591A 10 \u500B\uFF09",
|
|
3382
|
+
example: ["5001001006", "5001001007"]
|
|
3383
|
+
},
|
|
3384
|
+
other_job_skill_description: {
|
|
3385
|
+
type: "string",
|
|
3386
|
+
required: false,
|
|
3387
|
+
htmlText: true,
|
|
3388
|
+
maxLength: 2e3,
|
|
3389
|
+
description: "\u5176\u4ED6\u5DE5\u4F5C\u6280\u80FD\u63CF\u8FF0\uFF08\u5BCC\u6587\u672C\uFF0C\u5BE6\u969B\u6587\u5B57\u4E0A\u9650 2000 \u5B57\uFF09",
|
|
3390
|
+
example: "\u719F\u6089 Scrum \u548C\u654F\u6377\u958B\u767C\u6D41\u7A0B"
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
};
|
|
3394
|
+
var autobiographySection = {
|
|
3395
|
+
key: "autobiography",
|
|
3396
|
+
kind: "scalar",
|
|
3397
|
+
description: "\u81EA\u50B3\uFF08\u7D14\u5B57\u4E32\u7BC0\uFF09",
|
|
3398
|
+
valueDef: {
|
|
3399
|
+
type: "string",
|
|
3400
|
+
required: true,
|
|
3401
|
+
htmlText: true,
|
|
3402
|
+
maxLength: 3e3,
|
|
3403
|
+
description: "\u81EA\u50B3\u5167\u5BB9\uFF08\u5BCC\u6587\u672C\u683C\u5F0F\uFF09\uFF0C\u5BE6\u969B\u6587\u5B57\u9577\u5EA6\u4E0D\u8D85\u904E 3000 \u5B57",
|
|
3404
|
+
example: "<p>This is my <strong>autobiography</strong>.</p>"
|
|
3405
|
+
}
|
|
3406
|
+
};
|
|
3407
|
+
var jobConditionSection = {
|
|
3408
|
+
key: "job_condition",
|
|
3409
|
+
kind: "object",
|
|
3410
|
+
description: "\u5E0C\u671B\u5DE5\u4F5C\u689D\u4EF6",
|
|
3411
|
+
fields: {
|
|
3412
|
+
feature_codes: {
|
|
3413
|
+
type: "enum[]",
|
|
3414
|
+
required: true,
|
|
3415
|
+
enumValues: JOB_FEATURE_CODE_VALUES,
|
|
3416
|
+
description: "\u5E0C\u671B\u6027\u8CEA\u4EE3\u78BC\uFF08\u591A\u9078\uFF09",
|
|
3417
|
+
example: ["full_time", "part_time"]
|
|
3418
|
+
},
|
|
3419
|
+
working_hour_type_codes: {
|
|
3420
|
+
type: "enum[]",
|
|
3421
|
+
required: true,
|
|
3422
|
+
enumValues: WORKING_HOUR_TYPE_VALUES,
|
|
3423
|
+
description: "\u4E0A\u73ED\u6642\u6BB5\u4EE3\u78BC\uFF08\u591A\u9078\uFF09",
|
|
3424
|
+
example: ["day_shift", "evening_shift"]
|
|
3425
|
+
},
|
|
3426
|
+
available_start_type_code: {
|
|
3427
|
+
type: "enum",
|
|
3428
|
+
required: true,
|
|
3429
|
+
enumValues: AVAILABLE_START_TYPE_VALUES,
|
|
3430
|
+
description: "\u53EF\u4E0A\u73ED\u6642\u9593\u985E\u578B\u4EE3\u78BC",
|
|
3431
|
+
example: "available_after_hired"
|
|
3432
|
+
},
|
|
3433
|
+
available_start_date: {
|
|
3434
|
+
type: "date",
|
|
3435
|
+
required: false,
|
|
3436
|
+
description: "\u81EA\u8A02\u53EF\u4E0A\u73ED\u65E5\u671F\uFF08available_start_type_code \u70BA available_custom_date \u6642\u5FC5\u586B\uFF09",
|
|
3437
|
+
example: "2025-05-01"
|
|
3438
|
+
},
|
|
3439
|
+
available_start_period_code: {
|
|
3440
|
+
type: "enum",
|
|
3441
|
+
required: false,
|
|
3442
|
+
enumValues: AVAILABLE_START_PERIOD_VALUES,
|
|
3443
|
+
description: "\u9304\u53D6\u5F8C\u671F\u9593\u4EE3\u78BC\uFF08available_start_type_code \u70BA available_after_hired \u6642\u5FC5\u586B\uFF09",
|
|
3444
|
+
example: "week"
|
|
3445
|
+
},
|
|
3446
|
+
salary_expectation_type_code: {
|
|
3447
|
+
type: "enum",
|
|
3448
|
+
required: true,
|
|
3449
|
+
enumValues: SALARY_EXPECTATION_TYPE_VALUES,
|
|
3450
|
+
description: "\u5E0C\u671B\u5F85\u9047\u985E\u578B\u4EE3\u78BC",
|
|
3451
|
+
example: "negotiable"
|
|
3452
|
+
},
|
|
3453
|
+
salary_unit_code: {
|
|
3454
|
+
type: "enum",
|
|
3455
|
+
required: false,
|
|
3456
|
+
enumValues: SALARY_UNIT_VALUES,
|
|
3457
|
+
description: "\u85AA\u8CC7\u55AE\u4F4D\u4EE3\u78BC\uFF08salary_expectation_type_code \u70BA custom \u6642\u5FC5\u586B\uFF09",
|
|
3458
|
+
example: "monthly"
|
|
3459
|
+
},
|
|
3460
|
+
salary_amount: {
|
|
3461
|
+
type: "number",
|
|
3462
|
+
required: false,
|
|
3463
|
+
min: 0,
|
|
3464
|
+
description: "\u81EA\u8A02\u85AA\u8CC7\u91D1\u984D\uFF08salary_expectation_type_code \u70BA custom \u6642\u5FC5\u586B\uFF09",
|
|
3465
|
+
example: 5e4
|
|
3466
|
+
},
|
|
3467
|
+
expected_salary_currency_code: {
|
|
3468
|
+
type: "string",
|
|
3469
|
+
required: false,
|
|
3470
|
+
minLength: 3,
|
|
3471
|
+
maxLength: 3,
|
|
3472
|
+
description: "\u671F\u671B\u85AA\u8CC7\u5E63\u5225\u4EE3\u78BC\uFF08ISO 4217\uFF0Csalary_expectation_type_code \u70BA custom \u6642\u5FC5\u586B\uFF09",
|
|
3473
|
+
example: "TWD"
|
|
3474
|
+
},
|
|
3475
|
+
area_codes: {
|
|
3476
|
+
type: "string[]",
|
|
3477
|
+
required: false,
|
|
3478
|
+
description: "\u5E0C\u671B\u5730\u9EDE\u4EE3\u78BC\uFF08\u591A\u9078\uFF09",
|
|
3479
|
+
example: ["6001005009", "6001006008"]
|
|
3480
|
+
},
|
|
3481
|
+
job_classification_codes: {
|
|
3482
|
+
type: "string[]",
|
|
3483
|
+
required: false,
|
|
3484
|
+
description: "\u5E0C\u671B\u8077\u985E\u4EE3\u78BC\uFF08\u591A\u9078\uFF09",
|
|
3485
|
+
example: ["2002002006", "2003002007"]
|
|
3486
|
+
}
|
|
3487
|
+
},
|
|
3488
|
+
crossFieldRules: [
|
|
3489
|
+
{
|
|
3490
|
+
name: "job-condition-available-start",
|
|
3491
|
+
message: 'When available_start_type_code is "available_custom_date", available_start_date is required. When it is "available_after_hired", available_start_period_code is required.',
|
|
3492
|
+
check(item) {
|
|
3493
|
+
const code = item.available_start_type_code;
|
|
3494
|
+
if (code === "available_custom_date") {
|
|
3495
|
+
const v = item.available_start_date;
|
|
3496
|
+
return v !== void 0 && v !== null && v !== "";
|
|
3497
|
+
}
|
|
3498
|
+
if (code === "available_after_hired") {
|
|
3499
|
+
const v = item.available_start_period_code;
|
|
3500
|
+
return v !== void 0 && v !== null && v !== "";
|
|
3501
|
+
}
|
|
3502
|
+
return true;
|
|
3503
|
+
}
|
|
3504
|
+
},
|
|
3505
|
+
{
|
|
3506
|
+
name: "job-condition-salary-expectation",
|
|
3507
|
+
message: 'When salary_expectation_type_code is "custom", salary_unit_code, salary_amount, and expected_salary_currency_code are all required.',
|
|
3508
|
+
check(item) {
|
|
3509
|
+
if (item.salary_expectation_type_code !== "custom") return true;
|
|
3510
|
+
const hasUnit = item.salary_unit_code !== void 0 && item.salary_unit_code !== null && item.salary_unit_code !== "";
|
|
3511
|
+
const hasAmount = typeof item.salary_amount === "number";
|
|
3512
|
+
const hasCurrency = typeof item.expected_salary_currency_code === "string" && item.expected_salary_currency_code.length > 0;
|
|
3513
|
+
return hasUnit && hasAmount && hasCurrency;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
]
|
|
3517
|
+
};
|
|
3518
|
+
var portfolioLinksSection = {
|
|
3519
|
+
key: "portfolio_links",
|
|
3520
|
+
kind: "array",
|
|
3521
|
+
maxItems: 5,
|
|
3522
|
+
description: "\u4F5C\u54C1\u96C6\u9023\u7D50\uFF0C\u6700\u591A 5 \u7B46",
|
|
3523
|
+
fields: {
|
|
3524
|
+
link_title: {
|
|
3525
|
+
type: "string",
|
|
3526
|
+
required: true,
|
|
3527
|
+
minLength: 1,
|
|
3528
|
+
maxLength: 50,
|
|
3529
|
+
description: "\u4F5C\u54C1\u96C6\u6A19\u984C",
|
|
3530
|
+
example: "My Portfolio"
|
|
3531
|
+
},
|
|
3532
|
+
link_url: {
|
|
3533
|
+
type: "string",
|
|
3534
|
+
required: true,
|
|
3535
|
+
minLength: 1,
|
|
3536
|
+
maxLength: 2083,
|
|
3537
|
+
description: "\u4F5C\u54C1\u96C6\u9023\u7D50\uFF08URL\uFF09",
|
|
3538
|
+
example: "https://myportfolio.com"
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
};
|
|
3542
|
+
var backgroundSection = {
|
|
3543
|
+
key: "background",
|
|
3544
|
+
kind: "object",
|
|
3545
|
+
description: "\u80CC\u666F\u8CC7\u8A0A\uFF08\u99D5\u7167\uFF0F\u8ECA\u8F1B\uFF0F\u5175\u5F79\u72C0\u614B\uFF09",
|
|
3546
|
+
fields: {
|
|
3547
|
+
// identity_types(身分種類)刻意不列:屬會員個資唯讀例外(pm_27 欄位),寫聚合不收,
|
|
3548
|
+
// validate 對此鍵給 read-only 專屬訊息而非泛用 unknown-field(見 design doc §4.2)。
|
|
3549
|
+
driving_license_types: {
|
|
3550
|
+
type: "enum[]",
|
|
3551
|
+
required: false,
|
|
3552
|
+
enumValues: VEHICLE_OR_LICENSE_TYPE_VALUES,
|
|
3553
|
+
description: "\u99D5\u99DB\u57F7\u7167\u985E\u578B\u5217\u8868",
|
|
3554
|
+
example: ["scooter", "light_motorcycle"]
|
|
3555
|
+
},
|
|
3556
|
+
vehicle_types: {
|
|
3557
|
+
type: "enum[]",
|
|
3558
|
+
required: false,
|
|
3559
|
+
enumValues: VEHICLE_OR_LICENSE_TYPE_VALUES,
|
|
3560
|
+
description: "\u8ECA\u8F1B\u985E\u578B\u5217\u8868",
|
|
3561
|
+
example: ["scooter"]
|
|
3562
|
+
},
|
|
3563
|
+
job_status: {
|
|
3564
|
+
type: "enum",
|
|
3565
|
+
required: false,
|
|
3566
|
+
enumValues: JOB_STATUS_VALUES,
|
|
3567
|
+
description: "\u5DE5\u4F5C\u72C0\u614B",
|
|
3568
|
+
example: "employed"
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
};
|
|
3572
|
+
var SECTIONS = [
|
|
3573
|
+
educationSection,
|
|
3574
|
+
workExperienceSection,
|
|
3575
|
+
certificateSection,
|
|
3576
|
+
languageSection,
|
|
3577
|
+
professionalSkillsSection,
|
|
3578
|
+
autobiographySection,
|
|
3579
|
+
jobConditionSection,
|
|
3580
|
+
portfolioLinksSection,
|
|
3581
|
+
backgroundSection
|
|
3582
|
+
];
|
|
3583
|
+
function getSection(key) {
|
|
3584
|
+
return SECTIONS.find((s) => s.key === key);
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
// src/lib/resume-schema/to-json-schema.ts
|
|
3588
|
+
var JSON_SCHEMA_DRAFT = "http://json-schema.org/draft-07/schema#";
|
|
3589
|
+
function fieldDefToJsonSchema(field) {
|
|
3590
|
+
const out = {};
|
|
3591
|
+
switch (field.type) {
|
|
3592
|
+
case "string":
|
|
3593
|
+
out.type = "string";
|
|
3594
|
+
if (field.minLength !== void 0) out.minLength = field.minLength;
|
|
3595
|
+
if (field.maxLength !== void 0) out.maxLength = field.maxLength;
|
|
3596
|
+
break;
|
|
3597
|
+
case "number":
|
|
3598
|
+
out.type = "number";
|
|
3599
|
+
if (field.min !== void 0) out.minimum = field.min;
|
|
3600
|
+
if (field.max !== void 0) out.maximum = field.max;
|
|
3601
|
+
break;
|
|
3602
|
+
case "integer":
|
|
3603
|
+
out.type = "integer";
|
|
3604
|
+
if (field.min !== void 0) out.minimum = field.min;
|
|
3605
|
+
if (field.max !== void 0) out.maximum = field.max;
|
|
3606
|
+
break;
|
|
3607
|
+
case "boolean":
|
|
3608
|
+
out.type = "boolean";
|
|
3609
|
+
break;
|
|
3610
|
+
case "enum":
|
|
3611
|
+
out.type = "string";
|
|
3612
|
+
out.enum = field.enumValues ? [...field.enumValues] : [];
|
|
3613
|
+
break;
|
|
3614
|
+
case "string[]":
|
|
3615
|
+
out.type = "array";
|
|
3616
|
+
out.items = { type: "string" };
|
|
3617
|
+
if (field.minItems !== void 0) out.minItems = field.minItems;
|
|
3618
|
+
if (field.maxItems !== void 0) out.maxItems = field.maxItems;
|
|
3619
|
+
break;
|
|
3620
|
+
case "enum[]":
|
|
3621
|
+
out.type = "array";
|
|
3622
|
+
out.items = { type: "string", enum: field.enumValues ? [...field.enumValues] : [] };
|
|
3623
|
+
if (field.minItems !== void 0) out.minItems = field.minItems;
|
|
3624
|
+
if (field.maxItems !== void 0) out.maxItems = field.maxItems;
|
|
3625
|
+
break;
|
|
3626
|
+
case "date":
|
|
3627
|
+
out.type = "string";
|
|
3628
|
+
out.format = "date";
|
|
3629
|
+
break;
|
|
3630
|
+
}
|
|
3631
|
+
out.description = field.htmlText ? `${field.description}(\u5B57\u6578\u4E0A\u9650\u8A08\u7B97\u65B9\u5F0F:\u5148\u525D\u9664 HTML tag,\u4EE5\u7D14\u6587\u5B57\u9577\u5EA6\u8A08\u7B97)` : field.description;
|
|
3632
|
+
if (field.example !== void 0) out.example = field.example;
|
|
3633
|
+
return out;
|
|
3634
|
+
}
|
|
3635
|
+
function fieldsToPropertiesAndRequired(fields) {
|
|
3636
|
+
const properties = {};
|
|
3637
|
+
const required = [];
|
|
3638
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
3639
|
+
properties[key] = fieldDefToJsonSchema(field);
|
|
3640
|
+
if (field.required) required.push(key);
|
|
3641
|
+
}
|
|
3642
|
+
return { properties, required };
|
|
3643
|
+
}
|
|
3644
|
+
function buildSectionSchema(section) {
|
|
3645
|
+
if (section.kind === "scalar") {
|
|
3646
|
+
return fieldDefToJsonSchema(section.valueDef);
|
|
3647
|
+
}
|
|
3648
|
+
if (section.kind === "object") {
|
|
3649
|
+
const { properties, required } = fieldsToPropertiesAndRequired(section.fields);
|
|
3650
|
+
return {
|
|
3651
|
+
type: "object",
|
|
3652
|
+
description: section.description,
|
|
3653
|
+
additionalProperties: false,
|
|
3654
|
+
required,
|
|
3655
|
+
properties
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
if (section.kind === "array") {
|
|
3659
|
+
const { properties, required } = fieldsToPropertiesAndRequired(section.fields);
|
|
3660
|
+
const schema = {
|
|
3661
|
+
type: "array",
|
|
3662
|
+
description: section.description,
|
|
3663
|
+
items: { type: "object", additionalProperties: false, required, properties }
|
|
3664
|
+
};
|
|
3665
|
+
if (section.maxItems !== void 0) schema.maxItems = section.maxItems;
|
|
3666
|
+
return schema;
|
|
3667
|
+
}
|
|
3668
|
+
const wrapperParts = fieldsToPropertiesAndRequired(section.wrapperFields ?? {});
|
|
3669
|
+
const itemParts = fieldsToPropertiesAndRequired(section.fields);
|
|
3670
|
+
return {
|
|
3671
|
+
type: "object",
|
|
3672
|
+
description: section.description,
|
|
3673
|
+
additionalProperties: false,
|
|
3674
|
+
required: [...wrapperParts.required, "items"],
|
|
3675
|
+
properties: {
|
|
3676
|
+
...wrapperParts.properties,
|
|
3677
|
+
items: {
|
|
3678
|
+
type: "array",
|
|
3679
|
+
items: { type: "object", additionalProperties: false, required: itemParts.required, properties: itemParts.properties }
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
3683
|
+
}
|
|
3684
|
+
function buildAggregateJsonSchema(sectionKey) {
|
|
3685
|
+
if (sectionKey !== void 0) {
|
|
3686
|
+
const section = getSection(sectionKey);
|
|
3687
|
+
if (!section) {
|
|
3688
|
+
const allowed = SECTIONS.map((s) => s.key).join(", ");
|
|
3689
|
+
throw new CliError(`Unknown section "${sectionKey}". Allowed: ${allowed}`, ExitCode.InvalidArgument);
|
|
3690
|
+
}
|
|
3691
|
+
return buildSectionSchema(section);
|
|
3692
|
+
}
|
|
3693
|
+
const properties = {
|
|
3694
|
+
name: fieldDefToJsonSchema(TOP_LEVEL_FIELDS.name)
|
|
3695
|
+
};
|
|
3696
|
+
for (const section of SECTIONS) {
|
|
3697
|
+
properties[section.key] = buildSectionSchema(section);
|
|
3698
|
+
}
|
|
3699
|
+
return {
|
|
3700
|
+
$schema: JSON_SCHEMA_DRAFT,
|
|
3701
|
+
title: "wport resume aggregate",
|
|
3702
|
+
description: "wport \u5C65\u6B77\u805A\u5408\u683C\u5F0F(\u4F9B personal resumes create/update/validate \u4F7F\u7528)\u3002view \u8F38\u51FA\u53E6\u542B\u552F\u8B80\u7684 photo_url(\u6703\u54E1\u500B\u8CC7),\u4E0D\u5728\u6B64\u5BEB\u5165\u805A\u5408 schema \u5167,\u5982\u9700\u66F4\u65B0\u8ACB\u8D70 profile \u76F8\u95DC\u6D41\u7A0B\u3002",
|
|
3703
|
+
type: "object",
|
|
3704
|
+
additionalProperties: false,
|
|
3705
|
+
required: ["name"],
|
|
3706
|
+
properties
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
// src/commands/personal/resumes/schema.ts
|
|
3711
|
+
function registerPersonalResumesSchema(parent) {
|
|
3712
|
+
parent.command("schema").description("Print the resume aggregate JSON Schema (offline, no login required)").option("--section <name>", "print only the given section (e.g. education, work_experience)").action((opts) => {
|
|
3713
|
+
printJson(buildAggregateJsonSchema(opts.section));
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
// src/lib/resume-schema/validate.ts
|
|
3718
|
+
var TOP_LEVEL_READ_ONLY_KEYS = /* @__PURE__ */ new Set(["photo_url"]);
|
|
3719
|
+
var BACKGROUND_READ_ONLY_KEYS = /* @__PURE__ */ new Set(["identity_types"]);
|
|
3720
|
+
var NO_READ_ONLY_KEYS = /* @__PURE__ */ new Set();
|
|
3721
|
+
function itemAllowedKeys(fields) {
|
|
3722
|
+
return /* @__PURE__ */ new Set([...Object.keys(fields), "enc_id"]);
|
|
3723
|
+
}
|
|
3724
|
+
function isPlainObject(value) {
|
|
3725
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3726
|
+
}
|
|
3727
|
+
function describeType(value) {
|
|
3728
|
+
if (value === null) return "null";
|
|
3729
|
+
if (Array.isArray(value)) return "an array";
|
|
3730
|
+
return typeof value;
|
|
3731
|
+
}
|
|
3732
|
+
function unknownFieldMessage(key) {
|
|
3733
|
+
return `Unknown field "${key}" \u2014 not part of the resume schema`;
|
|
3734
|
+
}
|
|
3735
|
+
function readOnlyFieldMessage(key) {
|
|
3736
|
+
return `Field "${key}" is read-only (member profile data) \u2014 not part of the resume write aggregate`;
|
|
3737
|
+
}
|
|
3738
|
+
function stripHtml2(s) {
|
|
3739
|
+
return s.replace(/<[^>]*>/g, "");
|
|
3740
|
+
}
|
|
3741
|
+
function isValidDateString(s) {
|
|
3742
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
|
3743
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
3744
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
3745
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
|
|
3746
|
+
}
|
|
3747
|
+
function checkUnknownKeys(obj, allowedKeys, readOnlyKeys, pathFor, issues) {
|
|
3748
|
+
for (const key of Object.keys(obj)) {
|
|
3749
|
+
if (allowedKeys.has(key)) continue;
|
|
3750
|
+
if (readOnlyKeys.has(key)) {
|
|
3751
|
+
issues.push({ path: pathFor(key), message: readOnlyFieldMessage(key) });
|
|
3752
|
+
continue;
|
|
3753
|
+
}
|
|
3754
|
+
issues.push({ path: pathFor(key), message: unknownFieldMessage(key) });
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
function checkFieldValue(field, value, path, issues) {
|
|
3758
|
+
switch (field.type) {
|
|
3759
|
+
case "string": {
|
|
3760
|
+
if (typeof value !== "string") {
|
|
3761
|
+
issues.push({ path, message: `Expected a string (got ${describeType(value)})` });
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
const text = field.htmlText ? stripHtml2(value) : value;
|
|
3765
|
+
if (field.minLength !== void 0 && text.length < field.minLength) {
|
|
3766
|
+
issues.push({ path, message: `Must be at least ${field.minLength} characters (got ${text.length})` });
|
|
3767
|
+
}
|
|
3768
|
+
if (field.maxLength !== void 0 && text.length > field.maxLength) {
|
|
3769
|
+
issues.push({ path, message: `Must be at most ${field.maxLength} characters (got ${text.length})` });
|
|
3770
|
+
}
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
case "number": {
|
|
3774
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
3775
|
+
issues.push({ path, message: `Expected a number (got ${describeType(value)})` });
|
|
3776
|
+
return;
|
|
3777
|
+
}
|
|
3778
|
+
checkRange(field, value, path, issues);
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
case "integer": {
|
|
3782
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
3783
|
+
issues.push({ path, message: `Expected an integer (got ${describeType(value)})` });
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
checkRange(field, value, path, issues);
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
case "boolean": {
|
|
3790
|
+
if (typeof value !== "boolean") issues.push({ path, message: `Expected a boolean (got ${describeType(value)})` });
|
|
3791
|
+
return;
|
|
3792
|
+
}
|
|
3793
|
+
case "enum": {
|
|
3794
|
+
const allowed = field.enumValues ?? [];
|
|
3795
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
3796
|
+
issues.push({ path, message: `Invalid value ${JSON.stringify(value)} \u2014 allowed: ${allowed.join(", ")}` });
|
|
3797
|
+
}
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
case "string[]": {
|
|
3801
|
+
if (!Array.isArray(value)) {
|
|
3802
|
+
issues.push({ path, message: `Expected an array (got ${describeType(value)})` });
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
value.forEach((v, i) => {
|
|
3806
|
+
if (typeof v !== "string") issues.push({ path: `${path}[${i}]`, message: `Expected a string (got ${describeType(v)})` });
|
|
3807
|
+
});
|
|
3808
|
+
checkItemsCount(field, value, path, issues);
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
case "enum[]": {
|
|
3812
|
+
const allowed = field.enumValues ?? [];
|
|
3813
|
+
if (!Array.isArray(value)) {
|
|
3814
|
+
issues.push({ path, message: `Expected an array (got ${describeType(value)})` });
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
value.forEach((v, i) => {
|
|
3818
|
+
if (typeof v !== "string" || !allowed.includes(v)) {
|
|
3819
|
+
issues.push({ path: `${path}[${i}]`, message: `Invalid value ${JSON.stringify(v)} \u2014 allowed: ${allowed.join(", ")}` });
|
|
3820
|
+
}
|
|
3821
|
+
});
|
|
3822
|
+
checkItemsCount(field, value, path, issues);
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
3825
|
+
case "date": {
|
|
3826
|
+
if (typeof value !== "string" || !isValidDateString(value)) {
|
|
3827
|
+
issues.push({ path, message: `Expected a date string in YYYY-MM-DD format (got ${JSON.stringify(value)})` });
|
|
3828
|
+
}
|
|
3829
|
+
return;
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
function checkRange(field, value, path, issues) {
|
|
3834
|
+
if (field.min !== void 0 && value < field.min) issues.push({ path, message: `Must be >= ${field.min} (got ${value})` });
|
|
3835
|
+
if (field.max !== void 0 && value > field.max) issues.push({ path, message: `Must be <= ${field.max} (got ${value})` });
|
|
3836
|
+
}
|
|
3837
|
+
function checkItemsCount(field, value, path, issues) {
|
|
3838
|
+
if (field.minItems !== void 0 && value.length < field.minItems) {
|
|
3839
|
+
issues.push({ path, message: `Must have at least ${field.minItems} item(s) (got ${value.length})` });
|
|
3840
|
+
}
|
|
3841
|
+
if (field.maxItems !== void 0 && value.length > field.maxItems) {
|
|
3842
|
+
issues.push({ path, message: `Must have at most ${field.maxItems} item(s) (got ${value.length})` });
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
function checkFields(obj, fields, pathFor, issues) {
|
|
3846
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
3847
|
+
const value = obj[key];
|
|
3848
|
+
const path = pathFor(key);
|
|
3849
|
+
if (value === void 0) {
|
|
3850
|
+
if (field.required) issues.push({ path, message: `Missing required field "${key}"` });
|
|
3851
|
+
continue;
|
|
3852
|
+
}
|
|
3853
|
+
checkFieldValue(field, value, path, issues);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
function checkCrossFieldRules(rules, item, path, issues) {
|
|
3857
|
+
for (const rule of rules ?? []) {
|
|
3858
|
+
if (!rule.check(item)) issues.push({ path, message: rule.message });
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
function validateArraySection(section, value, issues) {
|
|
3862
|
+
const path = section.key;
|
|
3863
|
+
if (!Array.isArray(value)) {
|
|
3864
|
+
issues.push({ path, message: `Expected an array (got ${describeType(value)})` });
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
if (section.maxItems !== void 0 && value.length > section.maxItems) {
|
|
3868
|
+
issues.push({ path, message: `Must have at most ${section.maxItems} item(s) (got ${value.length})` });
|
|
3869
|
+
}
|
|
3870
|
+
value.forEach((item, i) => {
|
|
3871
|
+
const itemPath = `${path}[${i}]`;
|
|
3872
|
+
if (!isPlainObject(item)) {
|
|
3873
|
+
issues.push({ path: itemPath, message: `Expected an object (got ${describeType(item)})` });
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
checkUnknownKeys(item, itemAllowedKeys(section.fields), NO_READ_ONLY_KEYS, (k) => `${itemPath}.${k}`, issues);
|
|
3877
|
+
checkFields(item, section.fields, (k) => `${itemPath}.${k}`, issues);
|
|
3878
|
+
checkCrossFieldRules(section.crossFieldRules, item, itemPath, issues);
|
|
3879
|
+
});
|
|
3880
|
+
}
|
|
3881
|
+
function validateObjectSection(section, value, issues) {
|
|
3882
|
+
const path = section.key;
|
|
3883
|
+
if (!isPlainObject(value)) {
|
|
3884
|
+
issues.push({ path, message: `Expected an object (got ${describeType(value)})` });
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
const readOnly = section.key === "background" ? BACKGROUND_READ_ONLY_KEYS : NO_READ_ONLY_KEYS;
|
|
3888
|
+
checkUnknownKeys(value, new Set(Object.keys(section.fields)), readOnly, (k) => `${path}.${k}`, issues);
|
|
3889
|
+
checkFields(value, section.fields, (k) => `${path}.${k}`, issues);
|
|
3890
|
+
checkCrossFieldRules(section.crossFieldRules, value, path, issues);
|
|
3891
|
+
}
|
|
3892
|
+
function validateScalarSection(section, value, issues) {
|
|
3893
|
+
checkFieldValue(section.valueDef, value, section.key, issues);
|
|
3894
|
+
}
|
|
3895
|
+
function validateWrapperSection(section, value, issues) {
|
|
3896
|
+
const path = section.key;
|
|
3897
|
+
if (!isPlainObject(value)) {
|
|
3898
|
+
issues.push({ path, message: `Expected an object (got ${describeType(value)})` });
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
const wrapperFields = section.wrapperFields ?? {};
|
|
3902
|
+
const allowedTopKeys = /* @__PURE__ */ new Set([...Object.keys(wrapperFields), "items"]);
|
|
3903
|
+
checkUnknownKeys(value, allowedTopKeys, NO_READ_ONLY_KEYS, (k) => `${path}.${k}`, issues);
|
|
3904
|
+
checkFields(value, wrapperFields, (k) => `${path}.${k}`, issues);
|
|
3905
|
+
const items = value.items;
|
|
3906
|
+
const itemsPath = `${path}.items`;
|
|
3907
|
+
if (items === void 0) {
|
|
3908
|
+
issues.push({ path: itemsPath, message: 'Missing required field "items"' });
|
|
3909
|
+
} else if (!Array.isArray(items)) {
|
|
3910
|
+
issues.push({ path: itemsPath, message: `Expected an array (got ${describeType(items)})` });
|
|
3911
|
+
} else {
|
|
3912
|
+
items.forEach((item, i) => {
|
|
3913
|
+
const itemPath = `${itemsPath}[${i}]`;
|
|
3914
|
+
if (!isPlainObject(item)) {
|
|
3915
|
+
issues.push({ path: itemPath, message: `Expected an object (got ${describeType(item)})` });
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
checkUnknownKeys(item, itemAllowedKeys(section.fields), NO_READ_ONLY_KEYS, (k) => `${itemPath}.${k}`, issues);
|
|
3919
|
+
checkFields(item, section.fields, (k) => `${itemPath}.${k}`, issues);
|
|
3920
|
+
checkCrossFieldRules(section.crossFieldRules, item, itemPath, issues);
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3923
|
+
checkCrossFieldRules(section.crossFieldRules, value, path, issues);
|
|
3924
|
+
}
|
|
3925
|
+
function validateAggregate(input) {
|
|
3926
|
+
if (!isPlainObject(input)) {
|
|
3927
|
+
return [{ path: "(root)", message: `Input must be a JSON object at the top level (got ${describeType(input)})` }];
|
|
3928
|
+
}
|
|
3929
|
+
const issues = [];
|
|
3930
|
+
const allowedTopKeys = /* @__PURE__ */ new Set(["name", ...SECTIONS.map((s) => s.key)]);
|
|
3931
|
+
checkUnknownKeys(input, allowedTopKeys, TOP_LEVEL_READ_ONLY_KEYS, (k) => k, issues);
|
|
3932
|
+
checkFields(input, TOP_LEVEL_FIELDS, (k) => k, issues);
|
|
3933
|
+
for (const section of SECTIONS) {
|
|
3934
|
+
const value = input[section.key];
|
|
3935
|
+
if (value === void 0) continue;
|
|
3936
|
+
switch (section.kind) {
|
|
3937
|
+
case "array":
|
|
3938
|
+
validateArraySection(section, value, issues);
|
|
3939
|
+
break;
|
|
3940
|
+
case "object":
|
|
3941
|
+
validateObjectSection(section, value, issues);
|
|
3942
|
+
break;
|
|
3943
|
+
case "scalar":
|
|
3944
|
+
validateScalarSection(section, value, issues);
|
|
3945
|
+
break;
|
|
3946
|
+
case "wrapper":
|
|
3947
|
+
validateWrapperSection(section, value, issues);
|
|
3948
|
+
break;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
return issues;
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
// src/commands/personal/resumes/validate.ts
|
|
3955
|
+
function runResumesValidate(ctx, filePath) {
|
|
3956
|
+
const input = readJsonInput(filePath, { timeoutMs: ctx.timeoutMs });
|
|
3957
|
+
const issues = validateAggregate(input);
|
|
3958
|
+
if (issues.length === 0) {
|
|
3959
|
+
if (ctx.format === "json") {
|
|
3960
|
+
printJson({ valid: true });
|
|
3961
|
+
} else {
|
|
3962
|
+
process.stdout.write("Valid.\n");
|
|
3963
|
+
}
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
if (ctx.format === "json") {
|
|
3967
|
+
printJson({ valid: false, issues });
|
|
3968
|
+
} else {
|
|
3969
|
+
for (const issue of issues) {
|
|
3970
|
+
process.stderr.write(`${sanitizeForTerminal(issue.path)}: ${sanitizeForTerminal(issue.message)}
|
|
3971
|
+
`);
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
throw new CliError(`${issues.length} validation issue(s) found`, ExitCode.InvalidArgument);
|
|
3975
|
+
}
|
|
3976
|
+
function registerPersonalResumesValidate(parent) {
|
|
3977
|
+
parent.command("validate").description("Validate a resume aggregate JSON file locally (offline, no login required)").requiredOption("--file <path>", 'path to a JSON resume aggregate, or "-" for stdin').action((flags, command) => {
|
|
3978
|
+
const ctx = resolveContext(command);
|
|
3979
|
+
runResumesValidate(ctx, flags.file);
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
// src/commands/personal/resumes/template.ts
|
|
3984
|
+
var import_node_fs8 = require("fs");
|
|
3985
|
+
|
|
3986
|
+
// src/lib/resume-schema/template.ts
|
|
3987
|
+
function buildFieldsExample(fields) {
|
|
3988
|
+
const out = {};
|
|
3989
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
3990
|
+
if (field.example !== void 0) out[key] = field.example;
|
|
3991
|
+
}
|
|
3992
|
+
return out;
|
|
3993
|
+
}
|
|
3994
|
+
function buildSectionTemplate(section) {
|
|
3995
|
+
switch (section.kind) {
|
|
3996
|
+
case "scalar":
|
|
3997
|
+
return section.valueDef.example;
|
|
3998
|
+
case "object":
|
|
3999
|
+
return buildFieldsExample(section.fields);
|
|
4000
|
+
case "array":
|
|
4001
|
+
return [buildFieldsExample(section.fields)];
|
|
4002
|
+
case "wrapper":
|
|
4003
|
+
return {
|
|
4004
|
+
...buildFieldsExample(section.wrapperFields ?? {}),
|
|
4005
|
+
items: [buildFieldsExample(section.fields)]
|
|
4006
|
+
};
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
function buildTemplate() {
|
|
4010
|
+
const template = {
|
|
4011
|
+
name: TOP_LEVEL_FIELDS.name.example
|
|
4012
|
+
};
|
|
4013
|
+
for (const section of SECTIONS) {
|
|
4014
|
+
template[section.key] = buildSectionTemplate(section);
|
|
4015
|
+
}
|
|
4016
|
+
return template;
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
// src/commands/personal/resumes/template.ts
|
|
4020
|
+
function runResumesTemplate(outPath) {
|
|
4021
|
+
const template = buildTemplate();
|
|
4022
|
+
if (outPath === void 0) {
|
|
4023
|
+
printJson(template);
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
if ((0, import_node_fs8.existsSync)(outPath)) {
|
|
4027
|
+
throw new InvalidArgumentError(`File already exists: ${outPath} (refusing to overwrite \u2014 remove it or choose a different --out path)`);
|
|
4028
|
+
}
|
|
4029
|
+
try {
|
|
4030
|
+
(0, import_node_fs8.writeFileSync)(outPath, `${JSON.stringify(template, null, 2)}
|
|
4031
|
+
`, "utf8");
|
|
4032
|
+
} catch (err) {
|
|
4033
|
+
throw new InvalidArgumentError(`Failed to write template to ${outPath}: ${err.message}`);
|
|
4034
|
+
}
|
|
4035
|
+
process.stdout.write(`Wrote template to ${outPath}
|
|
4036
|
+
`);
|
|
4037
|
+
}
|
|
4038
|
+
function registerPersonalResumesTemplate(parent) {
|
|
4039
|
+
parent.command("template").description("Print (or write) a resume aggregate skeleton with example values for every field (offline, no login required)").option("--out <file>", "write the template to this file instead of stdout (fails if the file already exists)").action((flags) => {
|
|
4040
|
+
runResumesTemplate(flags.out);
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
// src/commands/personal/resumes/list.ts
|
|
4045
|
+
var MINIMAL_LIST_FIELDS4 = ["enc_id", "name", "updated_at", "is_complete", "is_published"];
|
|
4046
|
+
function formatDate6(value) {
|
|
4047
|
+
return value ? String(value).slice(0, 10) : "";
|
|
4048
|
+
}
|
|
4049
|
+
function formatQuotaLine(quota) {
|
|
4050
|
+
const status = quota.can_create ? "can create" : `cannot create${quota.reason_code ? ` (${quota.reason_code})` : ""}`;
|
|
4051
|
+
return `${quota.used}/${quota.max_resumes} used, ${status}.`;
|
|
4052
|
+
}
|
|
4053
|
+
async function fetchResumeList(opts) {
|
|
4054
|
+
const { body } = await personalGet(opts, PERSONAL_RESUMES_BASE);
|
|
4055
|
+
return unwrapDataResponse(body);
|
|
4056
|
+
}
|
|
4057
|
+
async function runResumesList(ctx, flags) {
|
|
4058
|
+
if (flags.fields && flags.minimal) {
|
|
4059
|
+
throw new CliError("Use either --fields or --minimal, not both", ExitCode.InvalidArgument);
|
|
4060
|
+
}
|
|
4061
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4062
|
+
const data = await fetchResumeList(opts);
|
|
4063
|
+
const projection = flags.minimal ? MINIMAL_LIST_FIELDS4 : flags.fields ? parseFieldsList(flags.fields) : void 0;
|
|
4064
|
+
if (projection || ctx.format === "json") {
|
|
4065
|
+
printJson(projection ? { ...data, resumes: data.resumes.map((r) => pickPaths(r, projection)) } : data);
|
|
4066
|
+
return;
|
|
4067
|
+
}
|
|
4068
|
+
printTable(
|
|
4069
|
+
data.resumes,
|
|
4070
|
+
[
|
|
4071
|
+
{ header: "ENC_ID", value: (r) => r.enc_id.slice(0, 14) },
|
|
4072
|
+
{ header: "NAME", value: (r) => r.name, maxWidth: 24 },
|
|
4073
|
+
{ header: "UPDATED", value: (r) => formatDate6(r.updated_at), maxWidth: 12 },
|
|
4074
|
+
{ header: "COMPLETE", value: (r) => r.is_complete ? "yes" : "no" },
|
|
4075
|
+
{ header: "PUBLISHED", value: (r) => r.is_published ? "yes" : "no" }
|
|
4076
|
+
],
|
|
4077
|
+
ctx.color
|
|
4078
|
+
);
|
|
4079
|
+
process.stdout.write(dim(formatQuotaLine(data.quota), ctx.color) + "\n");
|
|
4080
|
+
}
|
|
4081
|
+
function registerPersonalResumesList(parent) {
|
|
4082
|
+
parent.command("list").description("List your personal resumes").option("--fields <list>", "output selected fields as JSON (comma-separated dotted paths, applied to each resume)").option("--minimal", `output only ${MINIMAL_LIST_FIELDS4.join(",")} as JSON`).action(async (flags, command) => {
|
|
4083
|
+
await runResumesList(resolveContext(command), flags);
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
// src/commands/personal/resumes/view.ts
|
|
4088
|
+
async function fetchResumeAggregate(opts, encId) {
|
|
4089
|
+
const trimmed = encId.trim();
|
|
4090
|
+
if (!trimmed) {
|
|
4091
|
+
throw new CliError("enc_id must not be empty", ExitCode.InvalidArgument);
|
|
4092
|
+
}
|
|
4093
|
+
const { body } = await personalGet(opts, `${PERSONAL_RESUMES_BASE}/${encodeURIComponent(trimmed)}`);
|
|
4094
|
+
return unwrapDataResponse(body);
|
|
4095
|
+
}
|
|
4096
|
+
function summarizeSections(resume) {
|
|
4097
|
+
const rows = [{ section: "name", summary: resume.name }];
|
|
4098
|
+
for (const def of SECTIONS) {
|
|
4099
|
+
rows.push({ section: def.key, summary: summarizeSection(def, resume) });
|
|
4100
|
+
}
|
|
4101
|
+
return rows;
|
|
4102
|
+
}
|
|
4103
|
+
function summarizeSection(def, resume) {
|
|
4104
|
+
const value = resume[def.key];
|
|
4105
|
+
if (def.kind === "array") {
|
|
4106
|
+
return `${Array.isArray(value) ? value.length : 0} item(s)`;
|
|
4107
|
+
}
|
|
4108
|
+
if (def.kind === "wrapper") {
|
|
4109
|
+
const wrapper = value;
|
|
4110
|
+
if (wrapper?.has_no_work_experience) return "no work experience";
|
|
4111
|
+
return `${Array.isArray(wrapper?.items) ? wrapper.items.length : 0} item(s)`;
|
|
4112
|
+
}
|
|
4113
|
+
if (def.kind === "scalar") {
|
|
4114
|
+
return typeof value === "string" && value.length > 0 ? "present" : "absent";
|
|
4115
|
+
}
|
|
4116
|
+
return value && typeof value === "object" && Object.keys(value).length > 0 ? "present" : "absent";
|
|
4117
|
+
}
|
|
4118
|
+
async function runResumesView(ctx, encId, flags) {
|
|
4119
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4120
|
+
const resume = await fetchResumeAggregate(opts, encId);
|
|
4121
|
+
if (flags.fields) {
|
|
4122
|
+
printJson(pickPaths(resume, parseFieldsList(flags.fields)));
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
4125
|
+
if (ctx.format === "json") {
|
|
4126
|
+
printJson(resume);
|
|
4127
|
+
return;
|
|
4128
|
+
}
|
|
4129
|
+
printTable(
|
|
4130
|
+
summarizeSections(resume),
|
|
4131
|
+
[
|
|
4132
|
+
{ header: "SECTION", value: (r) => r.section, maxWidth: 20 },
|
|
4133
|
+
{ header: "SUMMARY", value: (r) => r.summary, maxWidth: 40 }
|
|
4134
|
+
],
|
|
4135
|
+
ctx.color
|
|
4136
|
+
);
|
|
4137
|
+
}
|
|
4138
|
+
function registerPersonalResumesView(parent) {
|
|
4139
|
+
parent.command("view <enc_id>").description("View one resume aggregate (all sections)").option("--fields <list>", "output selected fields as JSON (comma-separated dotted paths)").action(async (encId, flags, command) => {
|
|
4140
|
+
await runResumesView(resolveContext(command), encId, flags);
|
|
4141
|
+
});
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
// src/commands/personal/resumes/export.ts
|
|
4145
|
+
var import_node_fs9 = require("fs");
|
|
4146
|
+
var import_node_path4 = require("path");
|
|
4147
|
+
function safeResumeFilename(encId) {
|
|
4148
|
+
if (encId.includes("/") || encId.includes("\\") || encId === "." || encId === "..") {
|
|
4149
|
+
throw new CliError(
|
|
4150
|
+
`Unexpected enc_id from server: "${encId}" (contains path separators; refusing to write outside --out)`,
|
|
4151
|
+
ExitCode.ServerOrNetworkError
|
|
4152
|
+
);
|
|
4153
|
+
}
|
|
4154
|
+
return `resume-${encId}.json`;
|
|
4155
|
+
}
|
|
4156
|
+
async function runResumesExport(ctx, outDir) {
|
|
4157
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4158
|
+
const { resumes } = await fetchResumeList(opts);
|
|
4159
|
+
(0, import_node_fs9.mkdirSync)(outDir, { recursive: true });
|
|
4160
|
+
for (const item of resumes) {
|
|
4161
|
+
const filePath = (0, import_node_path4.join)(outDir, safeResumeFilename(item.enc_id));
|
|
4162
|
+
const aggregate = await fetchResumeAggregate(opts, item.enc_id);
|
|
4163
|
+
(0, import_node_fs9.writeFileSync)(filePath, `${JSON.stringify(aggregate, null, 2)}
|
|
4164
|
+
`, "utf8");
|
|
4165
|
+
}
|
|
4166
|
+
process.stdout.write(`Exported ${resumes.length} resume(s) to ${outDir}.
|
|
4167
|
+
`);
|
|
4168
|
+
}
|
|
4169
|
+
function registerPersonalResumesExport(parent) {
|
|
4170
|
+
parent.command("export").description("Export all your resumes as one JSON file per resume").option("--out <dir>", "destination directory (default: current directory)").action(async (flags, command) => {
|
|
4171
|
+
await runResumesExport(resolveContext(command), flags.out ?? process.cwd());
|
|
4172
|
+
});
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
// src/lib/resume-orchestrator.ts
|
|
4176
|
+
var import_node_crypto11 = require("crypto");
|
|
4177
|
+
function freshIdempotencyKey() {
|
|
4178
|
+
return { idempotencyKey: (0, import_node_crypto11.randomUUID)() };
|
|
4179
|
+
}
|
|
4180
|
+
function describeError(err) {
|
|
4181
|
+
return err instanceof Error ? err.message : String(err);
|
|
4182
|
+
}
|
|
4183
|
+
function extractErrorCode(body) {
|
|
4184
|
+
if (body && typeof body === "object" && typeof body.error_code === "string") {
|
|
4185
|
+
return body.error_code;
|
|
4186
|
+
}
|
|
4187
|
+
return null;
|
|
4188
|
+
}
|
|
4189
|
+
async function createShell(opts) {
|
|
4190
|
+
try {
|
|
4191
|
+
const { body } = await personalPost(opts, PERSONAL_RESUMES_BASE, {}, freshIdempotencyKey());
|
|
4192
|
+
return unwrapDataResponse(body).enc_id;
|
|
4193
|
+
} catch (err) {
|
|
4194
|
+
if (err instanceof ServerClientHttpError) {
|
|
4195
|
+
const code = extractErrorCode(err.body);
|
|
4196
|
+
if (code === "profile_incomplete") {
|
|
4197
|
+
throw new CliError(
|
|
4198
|
+
"Cannot create resume: your member profile is incomplete. Complete the required profile fields first, then retry (server: profile_incomplete).",
|
|
4199
|
+
ExitCode.ServerClientError
|
|
4200
|
+
);
|
|
4201
|
+
}
|
|
4202
|
+
if (code === "resume_limit_reached") {
|
|
4203
|
+
throw new CliError(
|
|
4204
|
+
"Cannot create resume: you have reached your resume limit. Delete an existing resume first, then retry (server: resume_limit_reached).",
|
|
4205
|
+
ExitCode.ServerClientError
|
|
4206
|
+
);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
throw err;
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
async function renameResume(opts, encId, name) {
|
|
4213
|
+
await personalPut(opts, `${PERSONAL_RESUMES_BASE}/${encodeURIComponent(encId)}/name`, { name }, freshIdempotencyKey());
|
|
4214
|
+
}
|
|
4215
|
+
function toWorkExperienceWriteItem(item) {
|
|
4216
|
+
const { is_current, ...rest } = item;
|
|
4217
|
+
return { ...rest, is_current: is_current ? 1 : 0 };
|
|
4218
|
+
}
|
|
4219
|
+
function splitItemEncId(item) {
|
|
4220
|
+
const { enc_id, ...rest } = item;
|
|
4221
|
+
const valid = typeof enc_id === "string" && enc_id.trim() ? enc_id.trim() : null;
|
|
4222
|
+
return [valid, rest];
|
|
4223
|
+
}
|
|
4224
|
+
async function writeSection(opts, encId, key, value, mode) {
|
|
4225
|
+
const plan = SECTION_WRITE_PLAN[key];
|
|
4226
|
+
const base = `${PERSONAL_RESUMES_BASE}/${encodeURIComponent(encId)}`;
|
|
4227
|
+
switch (plan.kind) {
|
|
4228
|
+
case "per-item-post": {
|
|
4229
|
+
for (const item of value) {
|
|
4230
|
+
const [itemEncId, body] = splitItemEncId(item);
|
|
4231
|
+
if (mode === "update" && itemEncId) {
|
|
4232
|
+
await personalPut(opts, `${base}${plan.path}/${encodeURIComponent(itemEncId)}`, body, freshIdempotencyKey());
|
|
4233
|
+
} else {
|
|
4234
|
+
await personalPost(opts, `${base}${plan.path}`, body, freshIdempotencyKey());
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
return;
|
|
4238
|
+
}
|
|
4239
|
+
case "work-experience": {
|
|
4240
|
+
const wrapper = value;
|
|
4241
|
+
if (wrapper.has_no_work_experience) {
|
|
4242
|
+
await personalPost(opts, `${base}${plan.path}/no-work-experience`, {}, freshIdempotencyKey());
|
|
4243
|
+
return;
|
|
4244
|
+
}
|
|
4245
|
+
for (const item of wrapper.items) {
|
|
4246
|
+
const [itemEncId, rest] = splitItemEncId(item);
|
|
4247
|
+
const body = toWorkExperienceWriteItem(rest);
|
|
4248
|
+
if (mode === "update" && itemEncId) {
|
|
4249
|
+
await personalPut(opts, `${base}${plan.path}`, { encId: itemEncId, ...body }, freshIdempotencyKey());
|
|
4250
|
+
} else {
|
|
4251
|
+
await personalPost(opts, `${base}${plan.path}`, body, freshIdempotencyKey());
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
4256
|
+
case "single-post": {
|
|
4257
|
+
const body = key === "autobiography" ? { autobiography: value } : value;
|
|
4258
|
+
const write = mode === "create" ? personalPost : personalPut;
|
|
4259
|
+
await write(opts, `${base}${plan.path}`, body, freshIdempotencyKey());
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
case "bulk-put": {
|
|
4263
|
+
const items = value;
|
|
4264
|
+
await personalPut(
|
|
4265
|
+
opts,
|
|
4266
|
+
`${base}${plan.path}`,
|
|
4267
|
+
{
|
|
4268
|
+
portfolio_links: items.map((item) => {
|
|
4269
|
+
const [itemEncId, rest] = splitItemEncId(item);
|
|
4270
|
+
return { encId: mode === "update" ? itemEncId : null, ...rest };
|
|
4271
|
+
})
|
|
4272
|
+
},
|
|
4273
|
+
freshIdempotencyKey()
|
|
4274
|
+
);
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
async function attemptStep(section, run) {
|
|
4280
|
+
try {
|
|
4281
|
+
await run();
|
|
4282
|
+
return { section, ok: true };
|
|
4283
|
+
} catch (err) {
|
|
4284
|
+
return { section, ok: false, error: describeError(err) };
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
async function createAggregate(opts, aggregate) {
|
|
4288
|
+
const encId = await createShell(opts);
|
|
4289
|
+
const reports = [];
|
|
4290
|
+
if (aggregate.name !== void 0) {
|
|
4291
|
+
reports.push(await attemptStep("name", () => renameResume(opts, encId, aggregate.name)));
|
|
4292
|
+
}
|
|
4293
|
+
for (const section of SECTIONS) {
|
|
4294
|
+
const value = aggregate[section.key];
|
|
4295
|
+
if (value === void 0) continue;
|
|
4296
|
+
reports.push(await attemptStep(section.key, () => writeSection(opts, encId, section.key, value, "create")));
|
|
4297
|
+
}
|
|
4298
|
+
return { enc_id: encId, reports, allOk: reports.every((r) => r.ok) };
|
|
4299
|
+
}
|
|
4300
|
+
async function updateAggregate(opts, encId, aggregate, onlySection) {
|
|
4301
|
+
const reports = [];
|
|
4302
|
+
if (onlySection !== void 0) {
|
|
4303
|
+
const section = getSection(onlySection);
|
|
4304
|
+
if (!section) {
|
|
4305
|
+
const allowed = SECTIONS.map((s) => s.key).join(", ");
|
|
4306
|
+
throw new CliError(`Unknown section "${onlySection}". Allowed: ${allowed}`, ExitCode.InvalidArgument);
|
|
4307
|
+
}
|
|
4308
|
+
const value = aggregate[onlySection];
|
|
4309
|
+
if (value === void 0) {
|
|
4310
|
+
throw new CliError(`Section "${onlySection}" has no data in the input file`, ExitCode.InvalidArgument);
|
|
4311
|
+
}
|
|
4312
|
+
reports.push(await attemptStep(section.key, () => writeSection(opts, encId, section.key, value, "update")));
|
|
4313
|
+
return { enc_id: encId, reports, allOk: reports.every((r) => r.ok) };
|
|
4314
|
+
}
|
|
4315
|
+
if (aggregate.name !== void 0) {
|
|
4316
|
+
reports.push(await attemptStep("name", () => renameResume(opts, encId, aggregate.name)));
|
|
4317
|
+
}
|
|
4318
|
+
for (const section of SECTIONS) {
|
|
4319
|
+
const value = aggregate[section.key];
|
|
4320
|
+
if (value === void 0) continue;
|
|
4321
|
+
reports.push(await attemptStep(section.key, () => writeSection(opts, encId, section.key, value, "update")));
|
|
4322
|
+
}
|
|
4323
|
+
return { enc_id: encId, reports, allOk: reports.every((r) => r.ok) };
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
// src/commands/personal/resumes/create.ts
|
|
4327
|
+
async function runResumesCreate(ctx, filePath) {
|
|
4328
|
+
const input = readJsonObject(filePath, { timeoutMs: ctx.timeoutMs });
|
|
4329
|
+
const issues = validateAggregate(input);
|
|
4330
|
+
if (issues.length > 0) {
|
|
4331
|
+
if (ctx.format === "json") {
|
|
4332
|
+
printJson({ valid: false, issues });
|
|
4333
|
+
} else {
|
|
4334
|
+
for (const issue of issues) {
|
|
4335
|
+
process.stderr.write(`${sanitizeForTerminal(issue.path)}: ${sanitizeForTerminal(issue.message)}
|
|
4336
|
+
`);
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
throw new CliError(`${issues.length} validation issue(s) found \u2014 fix locally before creating (no request sent)`, ExitCode.InvalidArgument);
|
|
4340
|
+
}
|
|
4341
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4342
|
+
const result = await createAggregate(opts, input);
|
|
4343
|
+
printOrchestrationResult(ctx, result);
|
|
4344
|
+
if (!result.allOk) {
|
|
4345
|
+
const failed = result.reports.filter((r) => !r.ok).map((r) => r.section);
|
|
4346
|
+
throw new CliError(
|
|
4347
|
+
`Resume ${result.enc_id} was created but ${failed.length} section(s) failed to write: ${failed.join(", ")}. Run \`wport personal resumes update ${result.enc_id} --section <name> --file <file>\` to retry the failed section(s).`,
|
|
4348
|
+
ExitCode.ServerClientError
|
|
4349
|
+
);
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
function printOrchestrationResult(ctx, result) {
|
|
4353
|
+
if (ctx.format === "json") {
|
|
4354
|
+
printJson({ enc_id: result.enc_id, reports: result.reports, all_ok: result.allOk });
|
|
4355
|
+
return;
|
|
4356
|
+
}
|
|
4357
|
+
process.stdout.write(`Resume: ${sanitizeForTerminal(result.enc_id ?? "")}
|
|
4358
|
+
`);
|
|
4359
|
+
printTable(
|
|
4360
|
+
result.reports,
|
|
4361
|
+
[
|
|
4362
|
+
{ header: "SECTION", value: (r) => r.section, maxWidth: 20 },
|
|
4363
|
+
{ header: "STATUS", value: (r) => r.ok ? "\u2713" : "\u2717" },
|
|
4364
|
+
{ header: "ERROR", value: (r) => r.error ?? "", maxWidth: 60 }
|
|
4365
|
+
],
|
|
4366
|
+
ctx.color
|
|
4367
|
+
);
|
|
4368
|
+
}
|
|
4369
|
+
function registerPersonalResumesCreate(parent) {
|
|
4370
|
+
parent.command("create").description('Create a resume from a JSON aggregate file (validates locally first; use "-" for stdin)').requiredOption("--file <path>", 'path to a JSON resume aggregate, or "-" for stdin').action(async (flags, command) => {
|
|
4371
|
+
await runResumesCreate(resolveContext(command), flags.file);
|
|
4372
|
+
});
|
|
4373
|
+
}
|
|
4374
|
+
|
|
4375
|
+
// src/commands/personal/resumes/update.ts
|
|
4376
|
+
function requireEncId2(encId) {
|
|
4377
|
+
const trimmed = encId.trim();
|
|
4378
|
+
if (!trimmed) {
|
|
4379
|
+
throw new CliError("enc_id must not be empty", ExitCode.InvalidArgument);
|
|
4380
|
+
}
|
|
4381
|
+
return trimmed;
|
|
4382
|
+
}
|
|
4383
|
+
async function runResumesUpdate(ctx, encId, filePath, flags) {
|
|
4384
|
+
const trimmed = requireEncId2(encId);
|
|
4385
|
+
const input = readJsonObject(filePath, { timeoutMs: ctx.timeoutMs });
|
|
4386
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4387
|
+
const result = await updateAggregate(opts, trimmed, input, flags.section);
|
|
4388
|
+
printOrchestrationResult2(ctx, result);
|
|
4389
|
+
if (!result.allOk) {
|
|
4390
|
+
const failed = result.reports.filter((r) => !r.ok).map((r) => r.section);
|
|
4391
|
+
throw new CliError(
|
|
4392
|
+
`Resume ${result.enc_id} was partially updated \u2014 ${failed.length} section(s) failed: ${failed.join(", ")}. Fix the input and retry with --section <name>.`,
|
|
4393
|
+
ExitCode.ServerClientError
|
|
4394
|
+
);
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
function printOrchestrationResult2(ctx, result) {
|
|
4398
|
+
if (ctx.format === "json") {
|
|
4399
|
+
printJson({ enc_id: result.enc_id, reports: result.reports, all_ok: result.allOk });
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
process.stdout.write(`Resume: ${sanitizeForTerminal(result.enc_id ?? "")}
|
|
4403
|
+
`);
|
|
4404
|
+
printTable(
|
|
4405
|
+
result.reports,
|
|
4406
|
+
[
|
|
4407
|
+
{ header: "SECTION", value: (r) => r.section, maxWidth: 20 },
|
|
4408
|
+
{ header: "STATUS", value: (r) => r.ok ? "\u2713" : "\u2717" },
|
|
4409
|
+
{ header: "ERROR", value: (r) => r.error ?? "", maxWidth: 60 }
|
|
4410
|
+
],
|
|
4411
|
+
ctx.color
|
|
4412
|
+
);
|
|
4413
|
+
}
|
|
4414
|
+
function registerPersonalResumesUpdate(parent) {
|
|
4415
|
+
parent.command("update <enc_id>").description("Update a resume aggregate (whole file, or one section with --section)").requiredOption("--file <path>", 'path to a JSON resume aggregate (or single-section content), or "-" for stdin').option("--section <name>", "only write this section (e.g. education, work_experience)").action(async (encId, flags, command) => {
|
|
4416
|
+
await runResumesUpdate(resolveContext(command), encId, flags.file, flags);
|
|
4417
|
+
});
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
// src/commands/personal/resumes/copy.ts
|
|
4421
|
+
var import_node_crypto12 = require("crypto");
|
|
4422
|
+
function requireEncId3(encId) {
|
|
4423
|
+
const trimmed = encId.trim();
|
|
4424
|
+
if (!trimmed) {
|
|
4425
|
+
throw new CliError("enc_id must not be empty", ExitCode.InvalidArgument);
|
|
4426
|
+
}
|
|
4427
|
+
return trimmed;
|
|
4428
|
+
}
|
|
4429
|
+
function printCopyResult(ctx, sourceEncId, newEncId) {
|
|
4430
|
+
if (ctx.format === "json") {
|
|
4431
|
+
printJson({ enc_id: newEncId });
|
|
4432
|
+
return;
|
|
4433
|
+
}
|
|
4434
|
+
process.stdout.write(`Copied resume ${sanitizeForTerminal(sourceEncId)} \u2192 new resume: ${sanitizeForTerminal(newEncId)}
|
|
4435
|
+
`);
|
|
4436
|
+
}
|
|
4437
|
+
async function runResumesCopy(ctx, encId, name) {
|
|
4438
|
+
const trimmed = requireEncId3(encId);
|
|
4439
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4440
|
+
const { body } = await personalPost(opts, `${PERSONAL_RESUMES_BASE}/${encodeURIComponent(trimmed)}/duplicate`, {}, { idempotencyKey: (0, import_node_crypto12.randomUUID)() });
|
|
4441
|
+
const { enc_id: newEncId } = unwrapDataResponse(body);
|
|
4442
|
+
printCopyResult(ctx, trimmed, newEncId);
|
|
4443
|
+
if (name === void 0) return;
|
|
4444
|
+
try {
|
|
4445
|
+
await personalPut(opts, `${PERSONAL_RESUMES_BASE}/${encodeURIComponent(newEncId)}/name`, { name }, { idempotencyKey: (0, import_node_crypto12.randomUUID)() });
|
|
4446
|
+
} catch (err) {
|
|
4447
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
4448
|
+
throw new CliError(
|
|
4449
|
+
`Resume ${trimmed} was copied to ${newEncId}, but renaming it to "${name}" failed: ${reason}. The copy exists under its server-assigned default name \u2014 this CLI has no standalone rename command in v1.`,
|
|
4450
|
+
ExitCode.ServerClientError
|
|
4451
|
+
);
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
function registerPersonalResumesCopy(parent) {
|
|
4455
|
+
parent.command("copy <enc_id>").description("Duplicate a resume (counts toward your resume limit)").option("--name <name>", "rename the new copy after duplicating").action(async (encId, flags, command) => {
|
|
4456
|
+
await runResumesCopy(resolveContext(command), encId, flags.name);
|
|
4457
|
+
});
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
// src/commands/personal/resumes/publish.ts
|
|
4461
|
+
var import_node_crypto13 = require("crypto");
|
|
4462
|
+
function requireEncId4(encId) {
|
|
4463
|
+
const trimmed = encId.trim();
|
|
4464
|
+
if (!trimmed) {
|
|
4465
|
+
throw new CliError("enc_id must not be empty", ExitCode.InvalidArgument);
|
|
4466
|
+
}
|
|
4467
|
+
return trimmed;
|
|
4468
|
+
}
|
|
4469
|
+
async function runResumesPublishTransition(ctx, encId, action) {
|
|
4470
|
+
const trimmed = requireEncId4(encId);
|
|
4471
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4472
|
+
const targetStatus = action === "publish";
|
|
4473
|
+
const { body } = await personalPatch(
|
|
4474
|
+
opts,
|
|
4475
|
+
`${PERSONAL_RESUMES_BASE}/${encodeURIComponent(trimmed)}/published-status`,
|
|
4476
|
+
{ target_status: targetStatus },
|
|
4477
|
+
{ idempotencyKey: (0, import_node_crypto13.randomUUID)() }
|
|
4478
|
+
);
|
|
4479
|
+
const result = unwrapDataResponse(body);
|
|
4480
|
+
if (ctx.format === "json") {
|
|
4481
|
+
printJson({ enc_id: trimmed, is_published: result.is_published });
|
|
4482
|
+
return;
|
|
4483
|
+
}
|
|
4484
|
+
const verb = action === "publish" ? "Published" : "Unpublished";
|
|
4485
|
+
process.stdout.write(`${verb} resume: ${sanitizeForTerminal(trimmed)}
|
|
4486
|
+
`);
|
|
4487
|
+
}
|
|
4488
|
+
function registerPersonalResumesPublish(parent) {
|
|
4489
|
+
parent.command("publish <enc_id>").description("Publish a resume (make it visible to employers)").action(async (encId, _flags, command) => {
|
|
4490
|
+
await runResumesPublishTransition(resolveContext(command), encId, "publish");
|
|
4491
|
+
});
|
|
4492
|
+
}
|
|
4493
|
+
function registerPersonalResumesUnpublish(parent) {
|
|
4494
|
+
parent.command("unpublish <enc_id>").description("Unpublish a resume (hide it from employers)").action(async (encId, _flags, command) => {
|
|
4495
|
+
await runResumesPublishTransition(resolveContext(command), encId, "unpublish");
|
|
4496
|
+
});
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
// src/commands/personal/resumes/delete.ts
|
|
4500
|
+
var import_node_crypto14 = require("crypto");
|
|
4501
|
+
function requireEncId5(encId) {
|
|
4502
|
+
const trimmed = encId.trim();
|
|
4503
|
+
if (!trimmed) {
|
|
4504
|
+
throw new CliError("enc_id must not be empty", ExitCode.InvalidArgument);
|
|
4505
|
+
}
|
|
4506
|
+
return trimmed;
|
|
4507
|
+
}
|
|
4508
|
+
async function runResumesDelete(ctx, encId, confirm) {
|
|
4509
|
+
if (!confirm) {
|
|
4510
|
+
throw new CliError("Refusing to delete without --confirm (destructive, irreversible)", ExitCode.InvalidArgument);
|
|
4511
|
+
}
|
|
4512
|
+
const trimmed = requireEncId5(encId);
|
|
4513
|
+
const opts = { baseUrl: ctx.baseUrl, locale: ctx.locale, timeoutMs: ctx.timeoutMs };
|
|
4514
|
+
await personalDelete(opts, `${PERSONAL_RESUMES_BASE}/${encodeURIComponent(trimmed)}`, { idempotencyKey: (0, import_node_crypto14.randomUUID)() });
|
|
4515
|
+
if (ctx.format === "json") {
|
|
4516
|
+
printJson({ enc_id: trimmed, deleted: true });
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4519
|
+
process.stdout.write(`Deleted resume: ${sanitizeForTerminal(trimmed)}
|
|
4520
|
+
`);
|
|
4521
|
+
}
|
|
4522
|
+
function registerPersonalResumesDelete(parent) {
|
|
4523
|
+
parent.command("delete <enc_id>").description("Delete a resume (destructive; requires --confirm)").option("--confirm", "confirm this destructive, irreversible delete").action(async (encId, flags, command) => {
|
|
4524
|
+
await runResumesDelete(resolveContext(command), encId, flags.confirm === true);
|
|
4525
|
+
});
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
// src/commands/personal/resumes/index.ts
|
|
4529
|
+
function registerPersonalResumesCommand(parent) {
|
|
4530
|
+
const resumes = parent.command("resumes").description("Manage your personal resumes");
|
|
4531
|
+
registerPersonalResumesSchema(resumes);
|
|
4532
|
+
registerPersonalResumesValidate(resumes);
|
|
4533
|
+
registerPersonalResumesTemplate(resumes);
|
|
4534
|
+
registerPersonalResumesList(resumes);
|
|
4535
|
+
registerPersonalResumesView(resumes);
|
|
4536
|
+
registerPersonalResumesExport(resumes);
|
|
4537
|
+
registerPersonalResumesCreate(resumes);
|
|
4538
|
+
registerPersonalResumesUpdate(resumes);
|
|
4539
|
+
registerPersonalResumesCopy(resumes);
|
|
4540
|
+
registerPersonalResumesPublish(resumes);
|
|
4541
|
+
registerPersonalResumesUnpublish(resumes);
|
|
4542
|
+
registerPersonalResumesDelete(resumes);
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
// src/commands/personal/index.ts
|
|
4546
|
+
function registerPersonalCommand(program2) {
|
|
4547
|
+
const personal = program2.command("personal").description("Manage your personal resumes and profile");
|
|
4548
|
+
registerPersonalResumesCommand(personal);
|
|
4549
|
+
}
|
|
4550
|
+
|
|
2496
4551
|
// src/index.ts
|
|
2497
4552
|
var program = new import_commander.Command();
|
|
2498
|
-
program.name("wport").description("wport CLI \u2014 terminal interface to the W101 Talent Search Hub public API").version("0.
|
|
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));
|
|
2499
4554
|
registerJobsCommand(program);
|
|
2500
4555
|
registerConfigCommand(program);
|
|
2501
4556
|
registerDoctorCommand(program);
|
|
2502
4557
|
registerEnterpriseCommand(program);
|
|
4558
|
+
registerLoginCommand(program);
|
|
4559
|
+
registerWhoamiCommand(program);
|
|
4560
|
+
registerLogoutCommand(program);
|
|
4561
|
+
registerSessionsCommand(program);
|
|
4562
|
+
registerPersonalCommand(program);
|
|
2503
4563
|
program.exitOverride();
|
|
2504
4564
|
program.parseAsync(process.argv).then(() => process.exit(ExitCode.Success)).catch((err) => handleTopLevelError(err));
|
|
2505
4565
|
function handleTopLevelError(err) {
|