@stalfh233/omc-cli 0.4.16 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -185,6 +185,8 @@ async function cmdDoctor(store, environment, serviceCode) {
185
185
  steps.push(`服务凭据 ok ${serviceCode} 鉴权参数形状正常(${probe.parameterCount} 项;掩码/占位/全 0 均未检出)`);
186
186
  else
187
187
  steps.push(`服务凭据 WARN ${serviceCode} ${probe.findings.length} 项问题(${probe.findings.map((finding) => `${finding.code}:${finding.valueKind}`).join("、")})——掩码/占位值不可用于真实调用;真值从任务环境事实取,不要抄 service.get 读回`);
188
+ if (serviceCode !== undefined)
189
+ steps.push(`连通性 -- 形状检查不能证明凭据有效;真实连通性用 method.test(平台侧发起调用)`);
188
190
  }
189
191
  }
190
192
  catch (error) {
@@ -30,6 +30,10 @@ export class ConfigStore {
30
30
  constructor(paths = defaultPaths()) {
31
31
  this.paths = paths;
32
32
  }
33
+ /** Config root (config file's directory) — token-cache placement (F1). */
34
+ get configPaths() {
35
+ return this.paths;
36
+ }
33
37
  atomicWrite(path, text, mode) {
34
38
  mkdirSync(dirname(path), { recursive: true });
35
39
  if (existsSync(path)) {
@@ -258,8 +258,56 @@ export function fieldTypeHint(type) {
258
258
  for (const [concept, family] of Object.entries(FIELD_TYPE_SYNONYMS))
259
259
  if (type.includes(concept) || concept.includes(type))
260
260
  return `「${type}」看起来是概念名而非族名;最接近的是 ${family}`;
261
+ // F2 (r10-friction): an off-by-one family guess (NUMBER→NUMERICAL measured
262
+ // live as a full extra round) deserves a did-you-mean, not just the full
263
+ // list. Closest match by edit distance over the normalized names.
264
+ const closest = closestFamilyName(type);
265
+ if (closest)
266
+ return `「${type}」不是族名;你要找的可能是 ${closest}`;
261
267
  return undefined;
262
268
  }
269
+ /** Case/underscore-insensitive closest match over the family list. */
270
+ function closestFamilyName(type) {
271
+ const wanted = type.toUpperCase().replace(/[\s_-]/g, "");
272
+ if (wanted.length < 2)
273
+ return undefined;
274
+ let best;
275
+ for (const name of Object.keys(FIELD_PROPERTY_TYPES)) {
276
+ const candidate = name.toUpperCase().replace(/[\s_-]/g, "");
277
+ // Three acceptance shapes, from exact-ish to fuzzy:
278
+ // 1. prefix containment (STAFF→STAFF_SELECTOR, DATETIME→DATE)
279
+ const contained = (candidate.startsWith(wanted) || wanted.startsWith(candidate)) && Math.min(wanted.length, candidate.length) >= 4;
280
+ // 2. small edit distance (NUMERIC→NUMERICAL, SHORTTEXT→SHORT_TEXT)
281
+ const distance = editDistance(wanted, candidate);
282
+ const nearMiss = distance <= 2;
283
+ // 3. shared prefix + proportional distance (NUMBER→NUMERICAL, the live-measured case)
284
+ const sharedPrefix = commonPrefixLength(wanted, candidate);
285
+ const proportional = sharedPrefix >= 3 && wanted.length >= 5 && distance / Math.max(wanted.length, candidate.length) <= 0.58;
286
+ const accepted = contained || nearMiss || proportional;
287
+ if (accepted && (!best || distance < best.distance))
288
+ best = { name, distance };
289
+ }
290
+ return best?.name;
291
+ }
292
+ function commonPrefixLength(a, b) {
293
+ let index = 0;
294
+ while (index < a.length && index < b.length && a[index] === b[index])
295
+ index++;
296
+ return index;
297
+ }
298
+ function editDistance(a, b) {
299
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
300
+ for (let i = 1; i <= a.length; i++) {
301
+ let previous = row[0];
302
+ row[0] = i;
303
+ for (let j = 1; j <= b.length; j++) {
304
+ const temp = row[j];
305
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
306
+ previous = temp;
307
+ }
308
+ }
309
+ return row[b.length];
310
+ }
263
311
  /** wire propertyType (int) -> form controlType (type-matrix.md §3.1). */
264
312
  export const FIELD_CONTROL_TYPES = { ...CAPABILITY_CONTROL_TYPES };
265
313
  export const SYSTEM_FIELD_CODES = new Set([
@@ -251,7 +251,7 @@ export function registerMetaLane(tools) {
251
251
  ? { step: "服务凭据", ok: null, serviceCode: probe.serviceCode, note: "未找到(get_by_code 无行)" }
252
252
  : probe.status === "error"
253
253
  ? { step: "服务凭据", ok: null, serviceCode: probe.serviceCode, note: `探测失败:${probe.message}` }
254
- : { step: "服务凭据", ok: probe.findings.length === 0, serviceCode: probe.serviceCode, adapterCode: probe.adapterCode, parameterCount: probe.parameterCount, findings: probe.findings, note: probe.findings.length === 0 ? "掩码/占位/全 0 均未检出" : "掩码/占位值不可用于真实调用;真值从任务环境事实取,不要抄 service.get 读回" });
254
+ : { step: "服务凭据", ok: probe.findings.length === 0, serviceCode: probe.serviceCode, adapterCode: probe.adapterCode, parameterCount: probe.parameterCount, findings: probe.findings, note: `${probe.findings.length === 0 ? "掩码/占位/全 0 均未检出" : "掩码/占位值不可用于真实调用;真值从任务环境事实取,不要抄 service.get 读回"};形状检查不能证明凭据有效,真实连通性用 method.test` });
255
255
  }
256
256
  return { status: "ok", steps };
257
257
  }
@@ -1,4 +1,5 @@
1
- import { CloudPivotSession } from "./session.js";
1
+ import { CloudPivotSession, defaultRequestTimeoutMs } from "./session.js";
2
+ import { TokenCache, loginFingerprint } from "./token-cache.js";
2
3
  /** Caches one logged-in session per named environment per process. */
3
4
  export class SessionManager {
4
5
  store;
@@ -20,7 +21,18 @@ export class SessionManager {
20
21
  let session = cached?.profileKey === profileKey ? cached.session : undefined;
21
22
  if (!session) {
22
23
  const credential = this.store.readCredential(resolved.name);
23
- session = new CloudPivotSession(resolved.name, resolved.profile, credential, this.httpFactory?.(resolved.profile), this.systemConfigFactory?.(resolved.profile));
24
+ // F1: cross-process token cache — lives beside the credentials, keyed
25
+ // by the environment profile + credential fingerprint. Injected http
26
+ // factories (tests, fakes) still go through the same hooks; a fake
27
+ // accepts the cached token the same way it accepts a fresh one.
28
+ const cache = new TokenCache(this.store.configPaths.configPath);
29
+ const environmentName = resolved.name;
30
+ const fingerprint = loginFingerprint(resolved.profile, credential);
31
+ session = new CloudPivotSession(resolved.name, resolved.profile, credential, this.httpFactory?.(resolved.profile), this.systemConfigFactory?.(resolved.profile), defaultRequestTimeoutMs(), {
32
+ load: () => cache.load(environmentName, fingerprint),
33
+ save: (login) => cache.save(environmentName, fingerprint, login),
34
+ clear: () => cache.clear(environmentName),
35
+ });
24
36
  this.sessions.set(resolved.name, { profileKey, session });
25
37
  }
26
38
  await session.login();
package/dist/session.js CHANGED
@@ -18,16 +18,18 @@ export class CloudPivotSession {
18
18
  credential;
19
19
  systemConfig;
20
20
  requestTimeoutMs;
21
+ tokenCache;
21
22
  loginResult;
22
23
  version;
23
24
  httpAdapter;
24
25
  fingerprintOf;
25
- constructor(environmentName, profile, credential, http, systemConfig = undefined, requestTimeoutMs = defaultRequestTimeoutMs()) {
26
+ constructor(environmentName, profile, credential, http, systemConfig = undefined, requestTimeoutMs = defaultRequestTimeoutMs(), tokenCache) {
26
27
  this.environmentName = environmentName;
27
28
  this.profile = profile;
28
29
  this.credential = credential;
29
30
  this.systemConfig = systemConfig;
30
31
  this.requestTimeoutMs = requestTimeoutMs;
32
+ this.tokenCache = tokenCache;
31
33
  this.httpAdapter = http;
32
34
  }
33
35
  async http() {
@@ -42,6 +44,24 @@ export class CloudPivotSession {
42
44
  async login() {
43
45
  if (this.loginResult)
44
46
  return this.loginResult;
47
+ // F1: a fresh-enough cached login (same environment profile + credential
48
+ // fingerprint) skips the six-round-trip handshake entirely. A 401
49
+ // mid-call clears the cache and re-logins (request() below).
50
+ const cached = this.tokenCache?.load();
51
+ if (cached) {
52
+ const core = await omcCore();
53
+ if (!this.fingerprintOf)
54
+ this.fingerprintOf = core.canonicalCloudPivotEnvironmentFingerprint;
55
+ this.loginResult = {
56
+ token: cached.token,
57
+ enterpriseId: cached.enterpriseId,
58
+ administrator: cached.administrator,
59
+ ...(cached.accountIdentity !== undefined ? { accountIdentity: cached.accountIdentity } : {}),
60
+ };
61
+ if (cached.version !== undefined)
62
+ this.version = cached.version;
63
+ return this.loginResult;
64
+ }
45
65
  const core = await omcCore();
46
66
  if (!this.fingerprintOf)
47
67
  this.fingerprintOf = core.canonicalCloudPivotEnvironmentFingerprint;
@@ -59,6 +79,13 @@ export class CloudPivotSession {
59
79
  ...(systemConfig ? { systemConfig } : {}),
60
80
  });
61
81
  this.version = detectVersion(systemConfig);
82
+ this.tokenCache?.save({
83
+ token: this.loginResult.token,
84
+ enterpriseId: this.loginResult.enterpriseId,
85
+ administrator: this.loginResult.administrator,
86
+ ...(this.loginResult.accountIdentity !== undefined ? { accountIdentity: this.loginResult.accountIdentity } : {}),
87
+ ...(this.version !== undefined ? { version: this.version } : {}),
88
+ });
62
89
  return this.loginResult;
63
90
  }
64
91
  /** Version-family evidence; absent evidence returns undefined (fail closed upstream). */
@@ -104,6 +131,8 @@ export class CloudPivotSession {
104
131
  return first;
105
132
  this.loginResult = undefined;
106
133
  this.version = undefined;
134
+ // The invalidated token must not be re-loaded by the re-login below.
135
+ this.tokenCache?.clear();
107
136
  return this.send(input);
108
137
  }
109
138
  async send(input) {
@@ -167,7 +196,7 @@ export class CloudPivotRequestTimeoutError extends Error {
167
196
  this.name = "CloudPivotRequestTimeoutError";
168
197
  }
169
198
  }
170
- function defaultRequestTimeoutMs() {
199
+ export function defaultRequestTimeoutMs() {
171
200
  const raw = process.env.OMC_HTTP_TIMEOUT_MS;
172
201
  const parsed = raw ? Number(raw) : NaN;
173
202
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 120_000;
@@ -0,0 +1,65 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ export function loginFingerprint(profile, credential) {
5
+ return createHash("sha256")
6
+ .update(JSON.stringify({ baseUrl: profile.baseUrl, applicationCode: profile.applicationCode, engineCode: profile.engineCode ?? null, username: credential.username, password: credential.password }))
7
+ .digest("hex");
8
+ }
9
+ const DEFAULT_TTL_MS = 8 * 60 * 60 * 1000;
10
+ export class TokenCache {
11
+ dir;
12
+ ttlMs;
13
+ constructor(configPath, ttlMs = Number(process.env.OMC_TOKEN_TTL_MS) > 0 ? Number(process.env.OMC_TOKEN_TTL_MS) : DEFAULT_TTL_MS) {
14
+ this.dir = join(dirname(configPath), "token-cache");
15
+ this.ttlMs = ttlMs;
16
+ }
17
+ load(environmentName, fingerprint) {
18
+ const file = join(this.dir, `${environmentName}.json`);
19
+ if (!existsSync(file))
20
+ return undefined;
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(readFileSync(file, "utf8"));
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (parsed.fingerprint !== fingerprint)
29
+ return undefined;
30
+ if (typeof parsed.storedAt !== "number" || Date.now() - parsed.storedAt > this.ttlMs)
31
+ return undefined;
32
+ if (typeof parsed.token !== "string" || typeof parsed.enterpriseId !== "string" || typeof parsed.administrator !== "boolean")
33
+ return undefined;
34
+ return {
35
+ token: parsed.token,
36
+ enterpriseId: parsed.enterpriseId,
37
+ administrator: parsed.administrator,
38
+ ...(parsed.accountIdentity !== undefined ? { accountIdentity: parsed.accountIdentity } : {}),
39
+ ...(parsed.version !== undefined ? { version: parsed.version } : {}),
40
+ };
41
+ }
42
+ save(environmentName, fingerprint, login) {
43
+ try {
44
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
45
+ const entry = { ...login, fingerprint, storedAt: Date.now() };
46
+ const file = join(this.dir, `${environmentName}.json`);
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 });
49
+ renameSync(tmp, file);
50
+ }
51
+ catch {
52
+ // The cache is an optimization, never a dependency: a failed write
53
+ // only means the next process logs in fresh.
54
+ }
55
+ }
56
+ clear(environmentName) {
57
+ try {
58
+ rmSync(join(this.dir, `${environmentName}.json`), { force: true });
59
+ }
60
+ catch {
61
+ // best effort
62
+ }
63
+ }
64
+ }
65
+ //# sourceMappingURL=token-cache.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "commander": "14.0.3",
54
- "omc-core": "0.4.16",
54
+ "omc-core": "0.4.17",
55
55
  "proper-lockfile": "4.1.2",
56
56
  "write-file-atomic": "6.0.0",
57
57
  "zod": "^3.23.8",