@clivly/core 0.7.1 → 0.7.3
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/entity-heuristics.cjs +27 -7
- package/dist/entity-heuristics.mjs +27 -7
- package/dist/env-file.cjs +59 -0
- package/dist/env-file.d.cts +23 -0
- package/dist/env-file.d.mts +23 -0
- package/dist/env-file.mjs +55 -0
- package/dist/integration-state.cjs +242 -0
- package/dist/integration-state.d.cts +88 -0
- package/dist/integration-state.d.mts +88 -0
- package/dist/integration-state.mjs +232 -0
- package/package.json +21 -1
|
@@ -101,7 +101,10 @@ const CONCEPT_FIELDS = {
|
|
|
101
101
|
"name",
|
|
102
102
|
"full_name",
|
|
103
103
|
"fullname",
|
|
104
|
-
"display_name"
|
|
104
|
+
"display_name",
|
|
105
|
+
"first_name",
|
|
106
|
+
"firstname",
|
|
107
|
+
"given_name"
|
|
105
108
|
],
|
|
106
109
|
required: false
|
|
107
110
|
}, {
|
|
@@ -224,12 +227,28 @@ function suggestCursorField(columnKeys) {
|
|
|
224
227
|
return null;
|
|
225
228
|
}
|
|
226
229
|
const MATERIALIZING = new Set(require_entity_config.MATERIALIZING_CONCEPTS);
|
|
230
|
+
const ENDS_IN_CONSONANT_Y = /[^aeiou]y$/;
|
|
231
|
+
const NEEDS_ES = /(s|x|z|ch|sh)$/;
|
|
232
|
+
const IRREGULAR_PLURALS = {
|
|
233
|
+
person: "people",
|
|
234
|
+
child: "children",
|
|
235
|
+
man: "men",
|
|
236
|
+
woman: "women"
|
|
237
|
+
};
|
|
238
|
+
function pluralize(name) {
|
|
239
|
+
const irregular = IRREGULAR_PLURALS[name];
|
|
240
|
+
if (irregular) return irregular;
|
|
241
|
+
if (ENDS_IN_CONSONANT_Y.test(name)) return `${name.slice(0, -1)}ies`;
|
|
242
|
+
if (NEEDS_ES.test(name)) return `${name}es`;
|
|
243
|
+
return `${name}s`;
|
|
244
|
+
}
|
|
227
245
|
function matchesConcept(tableName, concept) {
|
|
228
246
|
const normalized = tableName.toLowerCase();
|
|
229
247
|
const entry = TABLE_PATTERNS.find((p) => p.concept === concept);
|
|
230
248
|
if (!entry) return null;
|
|
231
|
-
|
|
232
|
-
if (
|
|
249
|
+
const candidates = [normalized, pluralize(normalized)];
|
|
250
|
+
if (candidates.some((candidate) => entry.names.includes(candidate))) return { exact: true };
|
|
251
|
+
if (entry.names.some((name) => candidates.some((candidate) => candidate.includes(name)))) return { exact: false };
|
|
233
252
|
return null;
|
|
234
253
|
}
|
|
235
254
|
function metaFor(table, column) {
|
|
@@ -245,17 +264,18 @@ function scoreConcepts(table) {
|
|
|
245
264
|
for (const concept of TARGET_CONCEPTS) {
|
|
246
265
|
if (!MATERIALIZING.has(concept)) continue;
|
|
247
266
|
const nameMatch = matchesConcept(table.name, concept);
|
|
248
|
-
|
|
267
|
+
const definingColumns = hasDefiningColumn(concept, table.columns);
|
|
268
|
+
if (!(nameMatch || definingColumns)) continue;
|
|
249
269
|
let score = 0;
|
|
250
270
|
const reasons = [];
|
|
251
|
-
if (nameMatch
|
|
271
|
+
if (nameMatch?.exact) {
|
|
252
272
|
score += require_mapping_score.W.nameExact;
|
|
253
273
|
reasons.push(`name matches "${table.name}"`);
|
|
254
|
-
} else {
|
|
274
|
+
} else if (nameMatch) {
|
|
255
275
|
score += require_mapping_score.W.nameSubstring;
|
|
256
276
|
reasons.push(`name looks like ${concept}`);
|
|
257
277
|
}
|
|
258
|
-
if (
|
|
278
|
+
if (definingColumns) {
|
|
259
279
|
score += require_mapping_score.W.definingColumn;
|
|
260
280
|
reasons.push(`has the columns a ${concept} needs`);
|
|
261
281
|
}
|
|
@@ -100,7 +100,10 @@ const CONCEPT_FIELDS = {
|
|
|
100
100
|
"name",
|
|
101
101
|
"full_name",
|
|
102
102
|
"fullname",
|
|
103
|
-
"display_name"
|
|
103
|
+
"display_name",
|
|
104
|
+
"first_name",
|
|
105
|
+
"firstname",
|
|
106
|
+
"given_name"
|
|
104
107
|
],
|
|
105
108
|
required: false
|
|
106
109
|
}, {
|
|
@@ -223,12 +226,28 @@ function suggestCursorField(columnKeys) {
|
|
|
223
226
|
return null;
|
|
224
227
|
}
|
|
225
228
|
const MATERIALIZING = new Set(MATERIALIZING_CONCEPTS);
|
|
229
|
+
const ENDS_IN_CONSONANT_Y = /[^aeiou]y$/;
|
|
230
|
+
const NEEDS_ES = /(s|x|z|ch|sh)$/;
|
|
231
|
+
const IRREGULAR_PLURALS = {
|
|
232
|
+
person: "people",
|
|
233
|
+
child: "children",
|
|
234
|
+
man: "men",
|
|
235
|
+
woman: "women"
|
|
236
|
+
};
|
|
237
|
+
function pluralize(name) {
|
|
238
|
+
const irregular = IRREGULAR_PLURALS[name];
|
|
239
|
+
if (irregular) return irregular;
|
|
240
|
+
if (ENDS_IN_CONSONANT_Y.test(name)) return `${name.slice(0, -1)}ies`;
|
|
241
|
+
if (NEEDS_ES.test(name)) return `${name}es`;
|
|
242
|
+
return `${name}s`;
|
|
243
|
+
}
|
|
226
244
|
function matchesConcept(tableName, concept) {
|
|
227
245
|
const normalized = tableName.toLowerCase();
|
|
228
246
|
const entry = TABLE_PATTERNS.find((p) => p.concept === concept);
|
|
229
247
|
if (!entry) return null;
|
|
230
|
-
|
|
231
|
-
if (
|
|
248
|
+
const candidates = [normalized, pluralize(normalized)];
|
|
249
|
+
if (candidates.some((candidate) => entry.names.includes(candidate))) return { exact: true };
|
|
250
|
+
if (entry.names.some((name) => candidates.some((candidate) => candidate.includes(name)))) return { exact: false };
|
|
232
251
|
return null;
|
|
233
252
|
}
|
|
234
253
|
function metaFor(table, column) {
|
|
@@ -244,17 +263,18 @@ function scoreConcepts(table) {
|
|
|
244
263
|
for (const concept of TARGET_CONCEPTS) {
|
|
245
264
|
if (!MATERIALIZING.has(concept)) continue;
|
|
246
265
|
const nameMatch = matchesConcept(table.name, concept);
|
|
247
|
-
|
|
266
|
+
const definingColumns = hasDefiningColumn(concept, table.columns);
|
|
267
|
+
if (!(nameMatch || definingColumns)) continue;
|
|
248
268
|
let score = 0;
|
|
249
269
|
const reasons = [];
|
|
250
|
-
if (nameMatch
|
|
270
|
+
if (nameMatch?.exact) {
|
|
251
271
|
score += W.nameExact;
|
|
252
272
|
reasons.push(`name matches "${table.name}"`);
|
|
253
|
-
} else {
|
|
273
|
+
} else if (nameMatch) {
|
|
254
274
|
score += W.nameSubstring;
|
|
255
275
|
reasons.push(`name looks like ${concept}`);
|
|
256
276
|
}
|
|
257
|
-
if (
|
|
277
|
+
if (definingColumns) {
|
|
258
278
|
score += W.definingColumn;
|
|
259
279
|
reasons.push(`has the columns a ${concept} needs`);
|
|
260
280
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/env-file.ts
|
|
3
|
+
const ENV_FRAMEWORKS = [
|
|
4
|
+
"next",
|
|
5
|
+
"tanstack-start",
|
|
6
|
+
"sveltekit",
|
|
7
|
+
"remix",
|
|
8
|
+
"nuxt",
|
|
9
|
+
"hono-workers"
|
|
10
|
+
];
|
|
11
|
+
const ENV_FILES = {
|
|
12
|
+
next: ".env.local",
|
|
13
|
+
"tanstack-start": ".env",
|
|
14
|
+
sveltekit: ".env",
|
|
15
|
+
remix: ".env",
|
|
16
|
+
nuxt: ".env",
|
|
17
|
+
"hono-workers": ".dev.vars"
|
|
18
|
+
};
|
|
19
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
20
|
+
function envFileFor(framework) {
|
|
21
|
+
return ENV_FILES[normalizeFramework(framework)] ?? ".env";
|
|
22
|
+
}
|
|
23
|
+
const ALIASES = {
|
|
24
|
+
next: "next",
|
|
25
|
+
nextjs: "next",
|
|
26
|
+
"next.js": "next",
|
|
27
|
+
"tanstack-start": "tanstack-start",
|
|
28
|
+
tanstack: "tanstack-start",
|
|
29
|
+
sveltekit: "sveltekit",
|
|
30
|
+
remix: "remix",
|
|
31
|
+
nuxt: "nuxt",
|
|
32
|
+
"hono-workers": "hono-workers",
|
|
33
|
+
hono: "hono-workers",
|
|
34
|
+
workers: "hono-workers"
|
|
35
|
+
};
|
|
36
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
37
|
+
function normalizeFramework(framework) {
|
|
38
|
+
if (!framework) return null;
|
|
39
|
+
return ALIASES[framework.toLowerCase()] ?? null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Which env file to actually write to.
|
|
43
|
+
*
|
|
44
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
45
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
46
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
47
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
48
|
+
* one someone already has.
|
|
49
|
+
*/
|
|
50
|
+
function resolveEnvFile(input) {
|
|
51
|
+
const conventional = envFileFor(input.framework);
|
|
52
|
+
if (input.existing.includes(conventional)) return conventional;
|
|
53
|
+
return input.existing[0] ?? conventional;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
exports.ENV_FRAMEWORKS = ENV_FRAMEWORKS;
|
|
57
|
+
exports.envFileFor = envFileFor;
|
|
58
|
+
exports.normalizeFramework = normalizeFramework;
|
|
59
|
+
exports.resolveEnvFile = resolveEnvFile;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/env-file.d.ts
|
|
2
|
+
declare const ENV_FRAMEWORKS: readonly ["next", "tanstack-start", "sveltekit", "remix", "nuxt", "hono-workers"];
|
|
3
|
+
type EnvFramework = (typeof ENV_FRAMEWORKS)[number];
|
|
4
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
5
|
+
declare function envFileFor(framework: string | null | undefined): string;
|
|
6
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
7
|
+
declare function normalizeFramework(framework: string | null | undefined): EnvFramework | null;
|
|
8
|
+
/**
|
|
9
|
+
* Which env file to actually write to.
|
|
10
|
+
*
|
|
11
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
12
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
13
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
14
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
15
|
+
* one someone already has.
|
|
16
|
+
*/
|
|
17
|
+
declare function resolveEnvFile(input: {
|
|
18
|
+
/** Candidate filenames that already contain the credential, if any. */
|
|
19
|
+
existing: string[];
|
|
20
|
+
framework: string | null | undefined;
|
|
21
|
+
}): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { ENV_FRAMEWORKS, EnvFramework, envFileFor, normalizeFramework, resolveEnvFile };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/env-file.d.ts
|
|
2
|
+
declare const ENV_FRAMEWORKS: readonly ["next", "tanstack-start", "sveltekit", "remix", "nuxt", "hono-workers"];
|
|
3
|
+
type EnvFramework = (typeof ENV_FRAMEWORKS)[number];
|
|
4
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
5
|
+
declare function envFileFor(framework: string | null | undefined): string;
|
|
6
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
7
|
+
declare function normalizeFramework(framework: string | null | undefined): EnvFramework | null;
|
|
8
|
+
/**
|
|
9
|
+
* Which env file to actually write to.
|
|
10
|
+
*
|
|
11
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
12
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
13
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
14
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
15
|
+
* one someone already has.
|
|
16
|
+
*/
|
|
17
|
+
declare function resolveEnvFile(input: {
|
|
18
|
+
/** Candidate filenames that already contain the credential, if any. */
|
|
19
|
+
existing: string[];
|
|
20
|
+
framework: string | null | undefined;
|
|
21
|
+
}): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { ENV_FRAMEWORKS, EnvFramework, envFileFor, normalizeFramework, resolveEnvFile };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/env-file.ts
|
|
2
|
+
const ENV_FRAMEWORKS = [
|
|
3
|
+
"next",
|
|
4
|
+
"tanstack-start",
|
|
5
|
+
"sveltekit",
|
|
6
|
+
"remix",
|
|
7
|
+
"nuxt",
|
|
8
|
+
"hono-workers"
|
|
9
|
+
];
|
|
10
|
+
const ENV_FILES = {
|
|
11
|
+
next: ".env.local",
|
|
12
|
+
"tanstack-start": ".env",
|
|
13
|
+
sveltekit: ".env",
|
|
14
|
+
remix: ".env",
|
|
15
|
+
nuxt: ".env",
|
|
16
|
+
"hono-workers": ".dev.vars"
|
|
17
|
+
};
|
|
18
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
19
|
+
function envFileFor(framework) {
|
|
20
|
+
return ENV_FILES[normalizeFramework(framework)] ?? ".env";
|
|
21
|
+
}
|
|
22
|
+
const ALIASES = {
|
|
23
|
+
next: "next",
|
|
24
|
+
nextjs: "next",
|
|
25
|
+
"next.js": "next",
|
|
26
|
+
"tanstack-start": "tanstack-start",
|
|
27
|
+
tanstack: "tanstack-start",
|
|
28
|
+
sveltekit: "sveltekit",
|
|
29
|
+
remix: "remix",
|
|
30
|
+
nuxt: "nuxt",
|
|
31
|
+
"hono-workers": "hono-workers",
|
|
32
|
+
hono: "hono-workers",
|
|
33
|
+
workers: "hono-workers"
|
|
34
|
+
};
|
|
35
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
36
|
+
function normalizeFramework(framework) {
|
|
37
|
+
if (!framework) return null;
|
|
38
|
+
return ALIASES[framework.toLowerCase()] ?? null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Which env file to actually write to.
|
|
42
|
+
*
|
|
43
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
44
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
45
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
46
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
47
|
+
* one someone already has.
|
|
48
|
+
*/
|
|
49
|
+
function resolveEnvFile(input) {
|
|
50
|
+
const conventional = envFileFor(input.framework);
|
|
51
|
+
if (input.existing.includes(conventional)) return conventional;
|
|
52
|
+
return input.existing[0] ?? conventional;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { ENV_FRAMEWORKS, envFileFor, normalizeFramework, resolveEnvFile };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/integration-state.ts
|
|
3
|
+
const INTEGRATION_STEPS = [
|
|
4
|
+
"credential",
|
|
5
|
+
"application",
|
|
6
|
+
"schema",
|
|
7
|
+
"mapping",
|
|
8
|
+
"trigger",
|
|
9
|
+
"sync",
|
|
10
|
+
"widget"
|
|
11
|
+
];
|
|
12
|
+
const INTEGRATION_STEP_LABELS = {
|
|
13
|
+
credential: "Secure credential",
|
|
14
|
+
application: "Application connected",
|
|
15
|
+
schema: "Schema discovered",
|
|
16
|
+
mapping: "Entities mapped",
|
|
17
|
+
trigger: "Clivly can start a sync",
|
|
18
|
+
sync: "First sync observed",
|
|
19
|
+
widget: "Chat widget installed"
|
|
20
|
+
};
|
|
21
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
22
|
+
const STEP_PREREQUISITES = {
|
|
23
|
+
credential: [],
|
|
24
|
+
application: ["credential"],
|
|
25
|
+
schema: ["application"],
|
|
26
|
+
mapping: ["schema"],
|
|
27
|
+
trigger: ["application"],
|
|
28
|
+
sync: ["mapping", "trigger"],
|
|
29
|
+
widget: ["application"]
|
|
30
|
+
};
|
|
31
|
+
function stepIndex(id) {
|
|
32
|
+
return INTEGRATION_STEPS.indexOf(id);
|
|
33
|
+
}
|
|
34
|
+
/** A heartbeat older than this means the app stopped checking in. */
|
|
35
|
+
const HEARTBEAT_STALE_MS = 600 * 1e3;
|
|
36
|
+
function formatAge(ms) {
|
|
37
|
+
if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
|
|
38
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
|
|
39
|
+
return `${Math.round(ms / 36e5)}h ago`;
|
|
40
|
+
}
|
|
41
|
+
function heartbeatAge(facts) {
|
|
42
|
+
return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
|
|
43
|
+
}
|
|
44
|
+
/** Is the raw condition behind each step met, ignoring prerequisites? */
|
|
45
|
+
function isSatisfied(id, facts) {
|
|
46
|
+
const age = heartbeatAge(facts);
|
|
47
|
+
switch (id) {
|
|
48
|
+
case "credential": return facts.hasCredential;
|
|
49
|
+
case "application": return age !== null && age < HEARTBEAT_STALE_MS;
|
|
50
|
+
case "schema": return facts.discoveredTableCount > 0;
|
|
51
|
+
case "mapping": return facts.mappedEntityCount > 0;
|
|
52
|
+
case "trigger": return facts.triggerConfigured;
|
|
53
|
+
case "sync": return facts.lastRun?.ok === true;
|
|
54
|
+
default: return facts.widgetInstalled;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function evidenceFor(id, facts) {
|
|
58
|
+
const age = heartbeatAge(facts);
|
|
59
|
+
if (id === "application" && age !== null) return `heartbeat ${formatAge(age)}`;
|
|
60
|
+
if (id === "schema" && facts.discoveredTableCount > 0) return `found ${facts.discoveredTableCount} tables`;
|
|
61
|
+
if (id === "mapping" && facts.mappedEntityCount > 0) return `${facts.mappedEntityCount} entity mapped`;
|
|
62
|
+
if (id === "trigger" && facts.triggerConfigured) return "trigger URL and secret set";
|
|
63
|
+
if (id === "sync" && facts.lastRun) return `last run ${formatAge(facts.now - facts.lastRun.at)}`;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function problemFor(id, facts) {
|
|
67
|
+
if (id === "sync" && facts.lastRun && !facts.lastRun.ok) return {
|
|
68
|
+
code: "sync_failed",
|
|
69
|
+
message: facts.lastRun.message ?? "The last sync run failed.",
|
|
70
|
+
fix: "Open the run log to see the host response."
|
|
71
|
+
};
|
|
72
|
+
if (id === "application") {
|
|
73
|
+
const age = heartbeatAge(facts);
|
|
74
|
+
if (age !== null && age >= HEARTBEAT_STALE_MS) return {
|
|
75
|
+
code: "heartbeat_stale",
|
|
76
|
+
message: `Your app stopped checking in ${formatAge(age)}.`,
|
|
77
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
78
|
+
};
|
|
79
|
+
if (age === null && facts.everConnected) return {
|
|
80
|
+
code: "heartbeat_stale",
|
|
81
|
+
message: "Your application stopped checking in.",
|
|
82
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
89
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
90
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
91
|
+
* ever surfaces.
|
|
92
|
+
*/
|
|
93
|
+
function resolveSteps(facts) {
|
|
94
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
95
|
+
const steps = [];
|
|
96
|
+
let activeTaken = false;
|
|
97
|
+
for (const id of INTEGRATION_STEPS) {
|
|
98
|
+
const unmet = STEP_PREREQUISITES[id].find((need) => !satisfied.has(need));
|
|
99
|
+
const label = INTEGRATION_STEP_LABELS[id];
|
|
100
|
+
if (isSatisfied(id, facts)) {
|
|
101
|
+
satisfied.add(id);
|
|
102
|
+
steps.push({
|
|
103
|
+
id,
|
|
104
|
+
label,
|
|
105
|
+
state: "done",
|
|
106
|
+
blockedBy: null,
|
|
107
|
+
evidence: evidenceFor(id, facts),
|
|
108
|
+
problem: null
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (unmet !== void 0) {
|
|
113
|
+
steps.push({
|
|
114
|
+
id,
|
|
115
|
+
label,
|
|
116
|
+
state: "blocked",
|
|
117
|
+
blockedBy: unmet,
|
|
118
|
+
evidence: null,
|
|
119
|
+
problem: null
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const problem = problemFor(id, facts);
|
|
124
|
+
let state = "active";
|
|
125
|
+
if (problem) state = "failed";
|
|
126
|
+
else if (id === "widget") state = "skipped";
|
|
127
|
+
const claimsAttention = state === "active" || state === "failed";
|
|
128
|
+
steps.push({
|
|
129
|
+
id,
|
|
130
|
+
label,
|
|
131
|
+
state: activeTaken && state === "active" ? "blocked" : state,
|
|
132
|
+
blockedBy: null,
|
|
133
|
+
evidence: evidenceFor(id, facts),
|
|
134
|
+
problem: activeTaken ? null : problem
|
|
135
|
+
});
|
|
136
|
+
if (claimsAttention) activeTaken = true;
|
|
137
|
+
}
|
|
138
|
+
return steps;
|
|
139
|
+
}
|
|
140
|
+
const STEP_STATUS_WHEN_ACTIVE = {
|
|
141
|
+
credential: "not_configured",
|
|
142
|
+
application: "awaiting_connection",
|
|
143
|
+
schema: "connected",
|
|
144
|
+
mapping: "mapping_required",
|
|
145
|
+
trigger: "trigger_required",
|
|
146
|
+
sync: "ready_to_sync",
|
|
147
|
+
widget: "healthy"
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
151
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
152
|
+
* ladder position, because they describe the integration as it is now rather
|
|
153
|
+
* than how far setup once got.
|
|
154
|
+
*/
|
|
155
|
+
function deriveStatus(steps, facts) {
|
|
156
|
+
if (facts.syncInFlight) return "syncing";
|
|
157
|
+
if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
|
|
158
|
+
if (steps.some((step) => step.state === "failed")) return "error";
|
|
159
|
+
const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
|
|
160
|
+
if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
|
|
161
|
+
return facts.warnings.length > 0 ? "warning" : "healthy";
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
165
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
166
|
+
* recommends — that pairing is what C-05 broke.
|
|
167
|
+
*/
|
|
168
|
+
const INSTALL_COMMAND = "npx clivly init";
|
|
169
|
+
const NEXT_BY_STEP = {
|
|
170
|
+
credential: {
|
|
171
|
+
label: "Create a credential",
|
|
172
|
+
command: null,
|
|
173
|
+
envFile: null
|
|
174
|
+
},
|
|
175
|
+
application: {
|
|
176
|
+
label: "Install Clivly in your app",
|
|
177
|
+
command: INSTALL_COMMAND,
|
|
178
|
+
envFile: ".env.local"
|
|
179
|
+
},
|
|
180
|
+
schema: {
|
|
181
|
+
label: "Check what the SDK reported",
|
|
182
|
+
command: "npx clivly status",
|
|
183
|
+
envFile: null
|
|
184
|
+
},
|
|
185
|
+
mapping: {
|
|
186
|
+
label: "Choose the table holding your people",
|
|
187
|
+
command: null,
|
|
188
|
+
envFile: null
|
|
189
|
+
},
|
|
190
|
+
trigger: {
|
|
191
|
+
label: "Let Clivly start a sync",
|
|
192
|
+
command: null,
|
|
193
|
+
envFile: null
|
|
194
|
+
},
|
|
195
|
+
sync: {
|
|
196
|
+
label: "Run the first sync",
|
|
197
|
+
command: "npx clivly sync",
|
|
198
|
+
envFile: null
|
|
199
|
+
},
|
|
200
|
+
widget: {
|
|
201
|
+
label: "Install the chat widget",
|
|
202
|
+
command: null,
|
|
203
|
+
envFile: null
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
208
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
209
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
210
|
+
* next-action slot, call this — do not write another one.
|
|
211
|
+
*/
|
|
212
|
+
function nextAction(steps, status) {
|
|
213
|
+
if (status === "healthy" || status === "syncing") return null;
|
|
214
|
+
const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
|
|
215
|
+
if (!blocking) return null;
|
|
216
|
+
if (blocking.problem?.code === "heartbeat_stale") return {
|
|
217
|
+
label: "Reconnect your app",
|
|
218
|
+
command: "npx clivly status",
|
|
219
|
+
envFile: null
|
|
220
|
+
};
|
|
221
|
+
return NEXT_BY_STEP[blocking.id];
|
|
222
|
+
}
|
|
223
|
+
function computeIntegrationState(facts) {
|
|
224
|
+
const steps = resolveSteps(facts);
|
|
225
|
+
const status = deriveStatus(steps, facts);
|
|
226
|
+
return {
|
|
227
|
+
status,
|
|
228
|
+
steps,
|
|
229
|
+
next: nextAction(steps, status)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
exports.INSTALL_COMMAND = INSTALL_COMMAND;
|
|
234
|
+
exports.INTEGRATION_STEPS = INTEGRATION_STEPS;
|
|
235
|
+
exports.INTEGRATION_STEP_LABELS = INTEGRATION_STEP_LABELS;
|
|
236
|
+
exports.STEP_PREREQUISITES = STEP_PREREQUISITES;
|
|
237
|
+
exports.computeIntegrationState = computeIntegrationState;
|
|
238
|
+
exports.deriveStatus = deriveStatus;
|
|
239
|
+
exports.formatAge = formatAge;
|
|
240
|
+
exports.nextAction = nextAction;
|
|
241
|
+
exports.resolveSteps = resolveSteps;
|
|
242
|
+
exports.stepIndex = stepIndex;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/integration-state.d.ts
|
|
2
|
+
declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema", "mapping", "trigger", "sync", "widget"];
|
|
3
|
+
type StepId = (typeof INTEGRATION_STEPS)[number];
|
|
4
|
+
/** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
|
|
5
|
+
type StepState = "done" | "active" | "blocked" | "failed" | "skipped";
|
|
6
|
+
type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" | "disconnected";
|
|
7
|
+
declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
|
|
8
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
9
|
+
declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
|
|
10
|
+
declare function stepIndex(id: StepId): number;
|
|
11
|
+
interface RunFact {
|
|
12
|
+
at: number;
|
|
13
|
+
message?: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface IntegrationFacts {
|
|
17
|
+
discoveredTableCount: number;
|
|
18
|
+
/**
|
|
19
|
+
* Has this app ever checked in? Distinguishes "never installed" from "was
|
|
20
|
+
* working, now silent" — the same absent heartbeat means different things,
|
|
21
|
+
* and only one of them is diagnosed rather than set up.
|
|
22
|
+
*/
|
|
23
|
+
everConnected: boolean;
|
|
24
|
+
hasCredential: boolean;
|
|
25
|
+
lastHeartbeatAt: number | null;
|
|
26
|
+
lastRun: RunFact | null;
|
|
27
|
+
mappedEntityCount: number;
|
|
28
|
+
now: number;
|
|
29
|
+
syncInFlight: boolean;
|
|
30
|
+
/** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
|
|
31
|
+
triggerConfigured: boolean;
|
|
32
|
+
warnings: string[];
|
|
33
|
+
widgetInstalled: boolean;
|
|
34
|
+
}
|
|
35
|
+
interface StepProblem {
|
|
36
|
+
code: string;
|
|
37
|
+
fix: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
interface IntegrationStep {
|
|
41
|
+
blockedBy: StepId | null;
|
|
42
|
+
evidence: string | null;
|
|
43
|
+
id: StepId;
|
|
44
|
+
label: string;
|
|
45
|
+
problem: StepProblem | null;
|
|
46
|
+
state: StepState;
|
|
47
|
+
}
|
|
48
|
+
declare function formatAge(ms: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
51
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
52
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
53
|
+
* ever surfaces.
|
|
54
|
+
*/
|
|
55
|
+
declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
|
|
56
|
+
/**
|
|
57
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
58
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
59
|
+
* ladder position, because they describe the integration as it is now rather
|
|
60
|
+
* than how far setup once got.
|
|
61
|
+
*/
|
|
62
|
+
declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
|
|
63
|
+
interface NextAction {
|
|
64
|
+
command: string | null;
|
|
65
|
+
envFile: string | null;
|
|
66
|
+
label: string;
|
|
67
|
+
}
|
|
68
|
+
interface IntegrationState {
|
|
69
|
+
next: NextAction | null;
|
|
70
|
+
status: IntegrationStatus;
|
|
71
|
+
steps: IntegrationStep[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
75
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
76
|
+
* recommends — that pairing is what C-05 broke.
|
|
77
|
+
*/
|
|
78
|
+
declare const INSTALL_COMMAND = "npx clivly init";
|
|
79
|
+
/**
|
|
80
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
81
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
82
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
83
|
+
* next-action slot, call this — do not write another one.
|
|
84
|
+
*/
|
|
85
|
+
declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
|
|
86
|
+
declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/integration-state.d.ts
|
|
2
|
+
declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema", "mapping", "trigger", "sync", "widget"];
|
|
3
|
+
type StepId = (typeof INTEGRATION_STEPS)[number];
|
|
4
|
+
/** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
|
|
5
|
+
type StepState = "done" | "active" | "blocked" | "failed" | "skipped";
|
|
6
|
+
type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" | "disconnected";
|
|
7
|
+
declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
|
|
8
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
9
|
+
declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
|
|
10
|
+
declare function stepIndex(id: StepId): number;
|
|
11
|
+
interface RunFact {
|
|
12
|
+
at: number;
|
|
13
|
+
message?: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface IntegrationFacts {
|
|
17
|
+
discoveredTableCount: number;
|
|
18
|
+
/**
|
|
19
|
+
* Has this app ever checked in? Distinguishes "never installed" from "was
|
|
20
|
+
* working, now silent" — the same absent heartbeat means different things,
|
|
21
|
+
* and only one of them is diagnosed rather than set up.
|
|
22
|
+
*/
|
|
23
|
+
everConnected: boolean;
|
|
24
|
+
hasCredential: boolean;
|
|
25
|
+
lastHeartbeatAt: number | null;
|
|
26
|
+
lastRun: RunFact | null;
|
|
27
|
+
mappedEntityCount: number;
|
|
28
|
+
now: number;
|
|
29
|
+
syncInFlight: boolean;
|
|
30
|
+
/** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
|
|
31
|
+
triggerConfigured: boolean;
|
|
32
|
+
warnings: string[];
|
|
33
|
+
widgetInstalled: boolean;
|
|
34
|
+
}
|
|
35
|
+
interface StepProblem {
|
|
36
|
+
code: string;
|
|
37
|
+
fix: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
interface IntegrationStep {
|
|
41
|
+
blockedBy: StepId | null;
|
|
42
|
+
evidence: string | null;
|
|
43
|
+
id: StepId;
|
|
44
|
+
label: string;
|
|
45
|
+
problem: StepProblem | null;
|
|
46
|
+
state: StepState;
|
|
47
|
+
}
|
|
48
|
+
declare function formatAge(ms: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
51
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
52
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
53
|
+
* ever surfaces.
|
|
54
|
+
*/
|
|
55
|
+
declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
|
|
56
|
+
/**
|
|
57
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
58
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
59
|
+
* ladder position, because they describe the integration as it is now rather
|
|
60
|
+
* than how far setup once got.
|
|
61
|
+
*/
|
|
62
|
+
declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
|
|
63
|
+
interface NextAction {
|
|
64
|
+
command: string | null;
|
|
65
|
+
envFile: string | null;
|
|
66
|
+
label: string;
|
|
67
|
+
}
|
|
68
|
+
interface IntegrationState {
|
|
69
|
+
next: NextAction | null;
|
|
70
|
+
status: IntegrationStatus;
|
|
71
|
+
steps: IntegrationStep[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
75
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
76
|
+
* recommends — that pairing is what C-05 broke.
|
|
77
|
+
*/
|
|
78
|
+
declare const INSTALL_COMMAND = "npx clivly init";
|
|
79
|
+
/**
|
|
80
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
81
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
82
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
83
|
+
* next-action slot, call this — do not write another one.
|
|
84
|
+
*/
|
|
85
|
+
declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
|
|
86
|
+
declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
//#region src/integration-state.ts
|
|
2
|
+
const INTEGRATION_STEPS = [
|
|
3
|
+
"credential",
|
|
4
|
+
"application",
|
|
5
|
+
"schema",
|
|
6
|
+
"mapping",
|
|
7
|
+
"trigger",
|
|
8
|
+
"sync",
|
|
9
|
+
"widget"
|
|
10
|
+
];
|
|
11
|
+
const INTEGRATION_STEP_LABELS = {
|
|
12
|
+
credential: "Secure credential",
|
|
13
|
+
application: "Application connected",
|
|
14
|
+
schema: "Schema discovered",
|
|
15
|
+
mapping: "Entities mapped",
|
|
16
|
+
trigger: "Clivly can start a sync",
|
|
17
|
+
sync: "First sync observed",
|
|
18
|
+
widget: "Chat widget installed"
|
|
19
|
+
};
|
|
20
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
21
|
+
const STEP_PREREQUISITES = {
|
|
22
|
+
credential: [],
|
|
23
|
+
application: ["credential"],
|
|
24
|
+
schema: ["application"],
|
|
25
|
+
mapping: ["schema"],
|
|
26
|
+
trigger: ["application"],
|
|
27
|
+
sync: ["mapping", "trigger"],
|
|
28
|
+
widget: ["application"]
|
|
29
|
+
};
|
|
30
|
+
function stepIndex(id) {
|
|
31
|
+
return INTEGRATION_STEPS.indexOf(id);
|
|
32
|
+
}
|
|
33
|
+
/** A heartbeat older than this means the app stopped checking in. */
|
|
34
|
+
const HEARTBEAT_STALE_MS = 600 * 1e3;
|
|
35
|
+
function formatAge(ms) {
|
|
36
|
+
if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
|
|
37
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
|
|
38
|
+
return `${Math.round(ms / 36e5)}h ago`;
|
|
39
|
+
}
|
|
40
|
+
function heartbeatAge(facts) {
|
|
41
|
+
return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
|
|
42
|
+
}
|
|
43
|
+
/** Is the raw condition behind each step met, ignoring prerequisites? */
|
|
44
|
+
function isSatisfied(id, facts) {
|
|
45
|
+
const age = heartbeatAge(facts);
|
|
46
|
+
switch (id) {
|
|
47
|
+
case "credential": return facts.hasCredential;
|
|
48
|
+
case "application": return age !== null && age < HEARTBEAT_STALE_MS;
|
|
49
|
+
case "schema": return facts.discoveredTableCount > 0;
|
|
50
|
+
case "mapping": return facts.mappedEntityCount > 0;
|
|
51
|
+
case "trigger": return facts.triggerConfigured;
|
|
52
|
+
case "sync": return facts.lastRun?.ok === true;
|
|
53
|
+
default: return facts.widgetInstalled;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function evidenceFor(id, facts) {
|
|
57
|
+
const age = heartbeatAge(facts);
|
|
58
|
+
if (id === "application" && age !== null) return `heartbeat ${formatAge(age)}`;
|
|
59
|
+
if (id === "schema" && facts.discoveredTableCount > 0) return `found ${facts.discoveredTableCount} tables`;
|
|
60
|
+
if (id === "mapping" && facts.mappedEntityCount > 0) return `${facts.mappedEntityCount} entity mapped`;
|
|
61
|
+
if (id === "trigger" && facts.triggerConfigured) return "trigger URL and secret set";
|
|
62
|
+
if (id === "sync" && facts.lastRun) return `last run ${formatAge(facts.now - facts.lastRun.at)}`;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function problemFor(id, facts) {
|
|
66
|
+
if (id === "sync" && facts.lastRun && !facts.lastRun.ok) return {
|
|
67
|
+
code: "sync_failed",
|
|
68
|
+
message: facts.lastRun.message ?? "The last sync run failed.",
|
|
69
|
+
fix: "Open the run log to see the host response."
|
|
70
|
+
};
|
|
71
|
+
if (id === "application") {
|
|
72
|
+
const age = heartbeatAge(facts);
|
|
73
|
+
if (age !== null && age >= HEARTBEAT_STALE_MS) return {
|
|
74
|
+
code: "heartbeat_stale",
|
|
75
|
+
message: `Your app stopped checking in ${formatAge(age)}.`,
|
|
76
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
77
|
+
};
|
|
78
|
+
if (age === null && facts.everConnected) return {
|
|
79
|
+
code: "heartbeat_stale",
|
|
80
|
+
message: "Your application stopped checking in.",
|
|
81
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
88
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
89
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
90
|
+
* ever surfaces.
|
|
91
|
+
*/
|
|
92
|
+
function resolveSteps(facts) {
|
|
93
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
94
|
+
const steps = [];
|
|
95
|
+
let activeTaken = false;
|
|
96
|
+
for (const id of INTEGRATION_STEPS) {
|
|
97
|
+
const unmet = STEP_PREREQUISITES[id].find((need) => !satisfied.has(need));
|
|
98
|
+
const label = INTEGRATION_STEP_LABELS[id];
|
|
99
|
+
if (isSatisfied(id, facts)) {
|
|
100
|
+
satisfied.add(id);
|
|
101
|
+
steps.push({
|
|
102
|
+
id,
|
|
103
|
+
label,
|
|
104
|
+
state: "done",
|
|
105
|
+
blockedBy: null,
|
|
106
|
+
evidence: evidenceFor(id, facts),
|
|
107
|
+
problem: null
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (unmet !== void 0) {
|
|
112
|
+
steps.push({
|
|
113
|
+
id,
|
|
114
|
+
label,
|
|
115
|
+
state: "blocked",
|
|
116
|
+
blockedBy: unmet,
|
|
117
|
+
evidence: null,
|
|
118
|
+
problem: null
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const problem = problemFor(id, facts);
|
|
123
|
+
let state = "active";
|
|
124
|
+
if (problem) state = "failed";
|
|
125
|
+
else if (id === "widget") state = "skipped";
|
|
126
|
+
const claimsAttention = state === "active" || state === "failed";
|
|
127
|
+
steps.push({
|
|
128
|
+
id,
|
|
129
|
+
label,
|
|
130
|
+
state: activeTaken && state === "active" ? "blocked" : state,
|
|
131
|
+
blockedBy: null,
|
|
132
|
+
evidence: evidenceFor(id, facts),
|
|
133
|
+
problem: activeTaken ? null : problem
|
|
134
|
+
});
|
|
135
|
+
if (claimsAttention) activeTaken = true;
|
|
136
|
+
}
|
|
137
|
+
return steps;
|
|
138
|
+
}
|
|
139
|
+
const STEP_STATUS_WHEN_ACTIVE = {
|
|
140
|
+
credential: "not_configured",
|
|
141
|
+
application: "awaiting_connection",
|
|
142
|
+
schema: "connected",
|
|
143
|
+
mapping: "mapping_required",
|
|
144
|
+
trigger: "trigger_required",
|
|
145
|
+
sync: "ready_to_sync",
|
|
146
|
+
widget: "healthy"
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
150
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
151
|
+
* ladder position, because they describe the integration as it is now rather
|
|
152
|
+
* than how far setup once got.
|
|
153
|
+
*/
|
|
154
|
+
function deriveStatus(steps, facts) {
|
|
155
|
+
if (facts.syncInFlight) return "syncing";
|
|
156
|
+
if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
|
|
157
|
+
if (steps.some((step) => step.state === "failed")) return "error";
|
|
158
|
+
const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
|
|
159
|
+
if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
|
|
160
|
+
return facts.warnings.length > 0 ? "warning" : "healthy";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
164
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
165
|
+
* recommends — that pairing is what C-05 broke.
|
|
166
|
+
*/
|
|
167
|
+
const INSTALL_COMMAND = "npx clivly init";
|
|
168
|
+
const NEXT_BY_STEP = {
|
|
169
|
+
credential: {
|
|
170
|
+
label: "Create a credential",
|
|
171
|
+
command: null,
|
|
172
|
+
envFile: null
|
|
173
|
+
},
|
|
174
|
+
application: {
|
|
175
|
+
label: "Install Clivly in your app",
|
|
176
|
+
command: INSTALL_COMMAND,
|
|
177
|
+
envFile: ".env.local"
|
|
178
|
+
},
|
|
179
|
+
schema: {
|
|
180
|
+
label: "Check what the SDK reported",
|
|
181
|
+
command: "npx clivly status",
|
|
182
|
+
envFile: null
|
|
183
|
+
},
|
|
184
|
+
mapping: {
|
|
185
|
+
label: "Choose the table holding your people",
|
|
186
|
+
command: null,
|
|
187
|
+
envFile: null
|
|
188
|
+
},
|
|
189
|
+
trigger: {
|
|
190
|
+
label: "Let Clivly start a sync",
|
|
191
|
+
command: null,
|
|
192
|
+
envFile: null
|
|
193
|
+
},
|
|
194
|
+
sync: {
|
|
195
|
+
label: "Run the first sync",
|
|
196
|
+
command: "npx clivly sync",
|
|
197
|
+
envFile: null
|
|
198
|
+
},
|
|
199
|
+
widget: {
|
|
200
|
+
label: "Install the chat widget",
|
|
201
|
+
command: null,
|
|
202
|
+
envFile: null
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
207
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
208
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
209
|
+
* next-action slot, call this — do not write another one.
|
|
210
|
+
*/
|
|
211
|
+
function nextAction(steps, status) {
|
|
212
|
+
if (status === "healthy" || status === "syncing") return null;
|
|
213
|
+
const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
|
|
214
|
+
if (!blocking) return null;
|
|
215
|
+
if (blocking.problem?.code === "heartbeat_stale") return {
|
|
216
|
+
label: "Reconnect your app",
|
|
217
|
+
command: "npx clivly status",
|
|
218
|
+
envFile: null
|
|
219
|
+
};
|
|
220
|
+
return NEXT_BY_STEP[blocking.id];
|
|
221
|
+
}
|
|
222
|
+
function computeIntegrationState(facts) {
|
|
223
|
+
const steps = resolveSteps(facts);
|
|
224
|
+
const status = deriveStatus(steps, facts);
|
|
225
|
+
return {
|
|
226
|
+
status,
|
|
227
|
+
steps,
|
|
228
|
+
next: nextAction(steps, status)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clivly/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ORM/auth-agnostic CRM adapter contracts and shared domain types for Clivly.",
|
|
@@ -47,6 +47,26 @@
|
|
|
47
47
|
"default": "./dist/mapping-score.cjs"
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
|
+
"./integration-state": {
|
|
51
|
+
"import": {
|
|
52
|
+
"types": "./dist/integration-state.d.mts",
|
|
53
|
+
"default": "./dist/integration-state.mjs"
|
|
54
|
+
},
|
|
55
|
+
"require": {
|
|
56
|
+
"types": "./dist/integration-state.d.cts",
|
|
57
|
+
"default": "./dist/integration-state.cjs"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"./env-file": {
|
|
61
|
+
"import": {
|
|
62
|
+
"types": "./dist/env-file.d.mts",
|
|
63
|
+
"default": "./dist/env-file.mjs"
|
|
64
|
+
},
|
|
65
|
+
"require": {
|
|
66
|
+
"types": "./dist/env-file.d.cts",
|
|
67
|
+
"default": "./dist/env-file.cjs"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
50
70
|
"./auth-adapter": {
|
|
51
71
|
"import": {
|
|
52
72
|
"types": "./dist/auth-adapter.d.mts",
|