@pi-stef/catalog 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +205 -0
- package/extensions/catalog.ts +7 -0
- package/package.json +42 -0
- package/src/catalog/crud.ts +143 -0
- package/src/catalog/install.ts +181 -0
- package/src/catalog/ratings.ts +32 -0
- package/src/catalog/reconcile.ts +339 -0
- package/src/catalog/source.ts +173 -0
- package/src/commands/add.ts +135 -0
- package/src/commands/definitions.ts +78 -0
- package/src/commands/diff.ts +158 -0
- package/src/commands/dispatch.ts +102 -0
- package/src/commands/init.ts +127 -0
- package/src/commands/login.ts +105 -0
- package/src/commands/profiles.ts +147 -0
- package/src/commands/remove.ts +90 -0
- package/src/commands/status.ts +142 -0
- package/src/commands/sync.ts +406 -0
- package/src/commands/toggle.ts +147 -0
- package/src/commands/types.ts +38 -0
- package/src/commands/verify.ts +107 -0
- package/src/config/io.ts +82 -0
- package/src/config/paths.ts +44 -0
- package/src/config/schema.ts +87 -0
- package/src/index.ts +94 -0
- package/src/profiles/manager.ts +159 -0
- package/src/register.ts +285 -0
- package/src/sync/auth.ts +109 -0
- package/src/sync/cache.ts +40 -0
- package/src/sync/gist.ts +253 -0
- package/src/sync/pull.ts +76 -0
- package/src/sync/push.ts +78 -0
- package/src/update/pi-update.ts +60 -0
- package/src/update/registry.ts +27 -0
- package/src/update/self-update.ts +60 -0
- package/src/update/semver.ts +38 -0
- package/src/update/types.ts +21 -0
- package/src/update/update-cache.ts +54 -0
- package/src/util/errors.ts +144 -0
- package/src/util/exec.ts +160 -0
package/src/sync/pull.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
import { CatalogYamlSchema, LockFileSchema } from "../config/schema.js";
|
|
4
|
+
import type { CatalogYaml, LockFile } from "../config/schema.js";
|
|
5
|
+
import { readGist, findGistByDescription } from "./gist.js";
|
|
6
|
+
import { readCachedGistId, writeCachedGistId } from "./cache.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** Result of a pull operation. */
|
|
13
|
+
export interface PullResult {
|
|
14
|
+
catalog: CatalogYaml;
|
|
15
|
+
lock: LockFile;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// pullCatalog
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a catalog and lock file from a GitHub Gist and deserialize them.
|
|
24
|
+
*
|
|
25
|
+
* Strategy:
|
|
26
|
+
* 1. Check for a cached gist ID in `~/.pi/sf/catalog/.gist`.
|
|
27
|
+
* 2. If no cached ID, search for an existing gist by description.
|
|
28
|
+
* 3. Fetch the gist and read its files.
|
|
29
|
+
* 4. Deserialize `cat.yaml` → CatalogYaml and `catalog.lock.json` → LockFile.
|
|
30
|
+
* 5. Cache the discovered gist ID for future pulls.
|
|
31
|
+
*
|
|
32
|
+
* Throws if no gist is found for the given profile.
|
|
33
|
+
*/
|
|
34
|
+
export async function pullCatalog(
|
|
35
|
+
profile: string,
|
|
36
|
+
home?: string,
|
|
37
|
+
): Promise<PullResult> {
|
|
38
|
+
const description = `catalog-${profile}`;
|
|
39
|
+
|
|
40
|
+
// 1. Check for cached gist ID
|
|
41
|
+
let gistId = readCachedGistId(home);
|
|
42
|
+
let discovered = false;
|
|
43
|
+
|
|
44
|
+
// 2. If no cached ID, find existing gist by description
|
|
45
|
+
if (!gistId) {
|
|
46
|
+
const existing = await findGistByDescription(description);
|
|
47
|
+
if (existing) {
|
|
48
|
+
gistId = existing.id;
|
|
49
|
+
discovered = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!gistId) {
|
|
54
|
+
throw new Error(`No gist found for profile "${profile}" (description: "${description}")`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3. Fetch gist files
|
|
58
|
+
const gist = await readGist(gistId);
|
|
59
|
+
|
|
60
|
+
// 4. Deserialize
|
|
61
|
+
const catYamlContent = gist.files["cat.yaml"]?.content ?? "";
|
|
62
|
+
const lockJsonContent = gist.files["catalog.lock.json"]?.content ?? "";
|
|
63
|
+
|
|
64
|
+
const parsedYaml = yaml.load(catYamlContent);
|
|
65
|
+
const catalog: CatalogYaml = CatalogYamlSchema.parse(parsedYaml);
|
|
66
|
+
|
|
67
|
+
const parsedLock = JSON.parse(lockJsonContent);
|
|
68
|
+
const lock: LockFile = LockFileSchema.parse(parsedLock);
|
|
69
|
+
|
|
70
|
+
// 5. Cache the discovered gist ID for future pulls
|
|
71
|
+
if (discovered) {
|
|
72
|
+
writeCachedGistId(gistId, home);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { catalog, lock };
|
|
76
|
+
}
|
package/src/sync/push.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
import type { CatalogYaml, LockFile } from "../config/schema.js";
|
|
4
|
+
import {
|
|
5
|
+
createGist,
|
|
6
|
+
updateGist,
|
|
7
|
+
findGistByDescription,
|
|
8
|
+
} from "./gist.js";
|
|
9
|
+
import { readCachedGistId, writeCachedGistId } from "./cache.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Result of a push operation. */
|
|
16
|
+
export interface PushResult {
|
|
17
|
+
gistId: string;
|
|
18
|
+
gistUrl: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// pushCatalog
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serialize a catalog and lock file, then push them to a GitHub Gist.
|
|
27
|
+
*
|
|
28
|
+
* Strategy:
|
|
29
|
+
* 1. Check for a cached gist ID in `~/.pi/sf/catalog/.gist`.
|
|
30
|
+
* 2. If no cached ID, search for an existing gist by description.
|
|
31
|
+
* 3. If found, update the gist; otherwise create a new one.
|
|
32
|
+
* 4. Cache the gist ID for future lookups.
|
|
33
|
+
*
|
|
34
|
+
* The gist description format is `catalog-<profile>`.
|
|
35
|
+
* The gist contains two files: `cat.yaml` and `catalog.lock.json`.
|
|
36
|
+
*/
|
|
37
|
+
export async function pushCatalog(
|
|
38
|
+
catalog: CatalogYaml,
|
|
39
|
+
lock: LockFile,
|
|
40
|
+
profile: string,
|
|
41
|
+
home?: string,
|
|
42
|
+
): Promise<PushResult> {
|
|
43
|
+
const description = `catalog-${profile}`;
|
|
44
|
+
|
|
45
|
+
const files: Record<string, string> = {
|
|
46
|
+
"cat.yaml": yaml.dump(catalog),
|
|
47
|
+
"catalog.lock.json": JSON.stringify(lock, null, 2),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 1. Check for cached gist ID
|
|
51
|
+
let gistId = readCachedGistId(home);
|
|
52
|
+
|
|
53
|
+
// 2. If no cached ID, find existing gist by description
|
|
54
|
+
if (!gistId) {
|
|
55
|
+
const existing = await findGistByDescription(description);
|
|
56
|
+
if (existing) {
|
|
57
|
+
gistId = existing.id;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let result;
|
|
62
|
+
|
|
63
|
+
if (gistId) {
|
|
64
|
+
// 3a. Update existing gist
|
|
65
|
+
result = await updateGist(gistId, files);
|
|
66
|
+
} else {
|
|
67
|
+
// 3b. Create new gist
|
|
68
|
+
result = await createGist(files, description);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 4. Cache the gist ID
|
|
72
|
+
writeCachedGistId(result.id, home);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
gistId: result.id,
|
|
76
|
+
gistUrl: result.url ?? `https://gist.github.com/${result.id}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi update checker for @earendil-works/pi-coding-agent.
|
|
3
|
+
*
|
|
4
|
+
* Checks npm registry for the latest version of pi,
|
|
5
|
+
* compares with the current version, and reports availability.
|
|
6
|
+
* Does NOT auto-install. Rate-limited to once per hour via lock file cache.
|
|
7
|
+
*/
|
|
8
|
+
import { fetchLatestVersion } from "./registry.js";
|
|
9
|
+
import { readUpdateCache, writeUpdateCache } from "./update-cache.js";
|
|
10
|
+
import { isNewer } from "./semver.js";
|
|
11
|
+
import type { UpdateCheckResult } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Cache key used in the lock file. */
|
|
14
|
+
const CACHE_KEY = "pi-update";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check whether a newer version of pi is available.
|
|
18
|
+
*
|
|
19
|
+
* @param currentVersion - The currently installed version string.
|
|
20
|
+
* @param home - Optional home directory override (for testing).
|
|
21
|
+
* @returns Update check result with current, latest, and updateAvailable.
|
|
22
|
+
*/
|
|
23
|
+
export async function checkPiUpdate(
|
|
24
|
+
currentVersion: string,
|
|
25
|
+
home?: string,
|
|
26
|
+
): Promise<UpdateCheckResult> {
|
|
27
|
+
// Check rate-limited cache first
|
|
28
|
+
const cached = readUpdateCache(CACHE_KEY, home);
|
|
29
|
+
if (cached) {
|
|
30
|
+
return {
|
|
31
|
+
current: currentVersion,
|
|
32
|
+
latest: cached.latest,
|
|
33
|
+
updateAvailable: isNewer(cached.latest, currentVersion),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch latest version from npm registry
|
|
38
|
+
const latest = await fetchLatestVersion("@earendil-works/pi-coding-agent");
|
|
39
|
+
|
|
40
|
+
if (latest === undefined) {
|
|
41
|
+
// Network error — skip silently
|
|
42
|
+
return {
|
|
43
|
+
current: currentVersion,
|
|
44
|
+
latest: undefined,
|
|
45
|
+
updateAvailable: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Cache the result
|
|
50
|
+
writeUpdateCache(CACHE_KEY, {
|
|
51
|
+
latest,
|
|
52
|
+
checkedAt: new Date().toISOString(),
|
|
53
|
+
}, home);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
current: currentVersion,
|
|
57
|
+
latest,
|
|
58
|
+
updateAvailable: isNewer(latest, currentVersion),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches the latest version of an npm package by running `npm view <pkg> version`.
|
|
3
|
+
*
|
|
4
|
+
* Returns the version string (trimmed), or undefined on any error.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export async function fetchLatestVersion(
|
|
9
|
+
packageName: string,
|
|
10
|
+
timeout = 10_000,
|
|
11
|
+
): Promise<string | undefined> {
|
|
12
|
+
return new Promise<string | undefined>((resolve) => {
|
|
13
|
+
execFile(
|
|
14
|
+
"npm",
|
|
15
|
+
["view", packageName, "version"],
|
|
16
|
+
{ timeout },
|
|
17
|
+
(error, stdout) => {
|
|
18
|
+
if (error) {
|
|
19
|
+
resolve(undefined);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const version = typeof stdout === "string" ? stdout.trim() : "";
|
|
23
|
+
resolve(version.length > 0 ? version : undefined);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update checker for @pi-stef/catalog.
|
|
3
|
+
*
|
|
4
|
+
* Checks npm registry for the latest version of @pi-stef/catalog,
|
|
5
|
+
* compares with the current version, and reports availability.
|
|
6
|
+
* Does NOT auto-install. Rate-limited to once per hour via lock file cache.
|
|
7
|
+
*/
|
|
8
|
+
import { fetchLatestVersion } from "./registry.js";
|
|
9
|
+
import { readUpdateCache, writeUpdateCache } from "./update-cache.js";
|
|
10
|
+
import { isNewer } from "./semver.js";
|
|
11
|
+
import type { UpdateCheckResult } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Cache key used in the lock file. */
|
|
14
|
+
const CACHE_KEY = "self-update";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check whether a newer version of @pi-stef/catalog is available.
|
|
18
|
+
*
|
|
19
|
+
* @param currentVersion - The currently installed version string.
|
|
20
|
+
* @param home - Optional home directory override (for testing).
|
|
21
|
+
* @returns Update check result with current, latest, and updateAvailable.
|
|
22
|
+
*/
|
|
23
|
+
export async function checkSelfUpdate(
|
|
24
|
+
currentVersion: string,
|
|
25
|
+
home?: string,
|
|
26
|
+
): Promise<UpdateCheckResult> {
|
|
27
|
+
// Check rate-limited cache first
|
|
28
|
+
const cached = readUpdateCache(CACHE_KEY, home);
|
|
29
|
+
if (cached) {
|
|
30
|
+
return {
|
|
31
|
+
current: currentVersion,
|
|
32
|
+
latest: cached.latest,
|
|
33
|
+
updateAvailable: isNewer(cached.latest, currentVersion),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch latest version from npm registry
|
|
38
|
+
const latest = await fetchLatestVersion("@pi-stef/catalog");
|
|
39
|
+
|
|
40
|
+
if (latest === undefined) {
|
|
41
|
+
// Network error — skip silently
|
|
42
|
+
return {
|
|
43
|
+
current: currentVersion,
|
|
44
|
+
latest: undefined,
|
|
45
|
+
updateAvailable: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Cache the result
|
|
50
|
+
writeUpdateCache(CACHE_KEY, {
|
|
51
|
+
latest,
|
|
52
|
+
checkedAt: new Date().toISOString(),
|
|
53
|
+
}, home);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
current: currentVersion,
|
|
57
|
+
latest,
|
|
58
|
+
updateAvailable: isNewer(latest, currentVersion),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple semver comparison utilities.
|
|
3
|
+
*
|
|
4
|
+
* Compares version strings of the form "major.minor.patch" (with optional
|
|
5
|
+
* pre-release tags). Returns -1 if a < b, 0 if equal, 1 if a > b.
|
|
6
|
+
*
|
|
7
|
+
* **Note:** Pre-release tags (e.g. `-beta`, `-rc.1`) are stripped before
|
|
8
|
+
* comparison, so `1.0.0-beta` compares equal to `1.0.0`. This is safe for
|
|
9
|
+
* the npm `latest` dist-tag, which never returns pre-release versions, but
|
|
10
|
+
* could misreport if a pre-release is ever returned.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compare two semver-like version strings.
|
|
15
|
+
* Pre-release tags are stripped (e.g. "1.0.0-beta" → "1.0.0").
|
|
16
|
+
* Returns -1 if a < b, 0 if a === b, 1 if a > b.
|
|
17
|
+
*/
|
|
18
|
+
export function compareVersions(a: string, b: string): number {
|
|
19
|
+
const partsA = a.replace(/-.*/, "").split(".").map(Number);
|
|
20
|
+
const partsB = b.replace(/-.*/, "").split(".").map(Number);
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const valA = partsA[i] ?? 0;
|
|
24
|
+
const valB = partsB[i] ?? 0;
|
|
25
|
+
if (valA < valB) return -1;
|
|
26
|
+
if (valA > valB) return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Versions are equal (ignoring pre-release)
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns true if `latest` is strictly greater than `current`.
|
|
35
|
+
*/
|
|
36
|
+
export function isNewer(latest: string, current: string): boolean {
|
|
37
|
+
return compareVersions(latest, current) > 0;
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for update checking.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Result of an update check. */
|
|
6
|
+
export interface UpdateCheckResult {
|
|
7
|
+
/** The currently installed version. */
|
|
8
|
+
current: string;
|
|
9
|
+
/** The latest version available on the registry (undefined on network error). */
|
|
10
|
+
latest: string | undefined;
|
|
11
|
+
/** Whether an update is available. */
|
|
12
|
+
updateAvailable: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Cache entry stored in the lock file under _updateCache. */
|
|
16
|
+
export interface UpdateCacheEntry {
|
|
17
|
+
/** The latest version found at check time. */
|
|
18
|
+
latest: string;
|
|
19
|
+
/** ISO-8601 timestamp of when the check was performed. */
|
|
20
|
+
checkedAt: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limited update check cache stored in the lock file.
|
|
3
|
+
*
|
|
4
|
+
* The cache lives under the `_updateCache` key in catalog.lock.json.
|
|
5
|
+
* Each checker has its own cache entry keyed by a cache key (e.g. "self-update").
|
|
6
|
+
*
|
|
7
|
+
* Rate limit: check no more than once per hour.
|
|
8
|
+
*/
|
|
9
|
+
import { readLock, writeLock } from "../config/io.js";
|
|
10
|
+
import type { UpdateCacheEntry } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** 1 hour in milliseconds. */
|
|
13
|
+
const RATE_LIMIT_MS = 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** The key used in the lock file for the update cache. */
|
|
16
|
+
const CACHE_KEY = "_updateCache";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read a cached update check result.
|
|
20
|
+
*
|
|
21
|
+
* Returns the cached entry if it exists and is less than 1 hour old,
|
|
22
|
+
* or undefined if no valid cache exists.
|
|
23
|
+
*/
|
|
24
|
+
export function readUpdateCache(
|
|
25
|
+
cacheKey: string,
|
|
26
|
+
home?: string,
|
|
27
|
+
): UpdateCacheEntry | undefined {
|
|
28
|
+
const lock = readLock(home) as Record<string, unknown>;
|
|
29
|
+
const cache = lock[CACHE_KEY] as Record<string, UpdateCacheEntry> | undefined;
|
|
30
|
+
if (!cache) return undefined;
|
|
31
|
+
|
|
32
|
+
const entry = cache[cacheKey];
|
|
33
|
+
if (!entry) return undefined;
|
|
34
|
+
|
|
35
|
+
const age = Date.now() - new Date(entry.checkedAt).getTime();
|
|
36
|
+
if (age >= RATE_LIMIT_MS) return undefined;
|
|
37
|
+
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write a cached update check result to the lock file.
|
|
43
|
+
*/
|
|
44
|
+
export function writeUpdateCache(
|
|
45
|
+
cacheKey: string,
|
|
46
|
+
entry: UpdateCacheEntry,
|
|
47
|
+
home?: string,
|
|
48
|
+
): void {
|
|
49
|
+
const lock = readLock(home) as Record<string, unknown>;
|
|
50
|
+
const cache = (lock[CACHE_KEY] ?? {}) as Record<string, UpdateCacheEntry>;
|
|
51
|
+
cache[cacheKey] = entry;
|
|
52
|
+
(lock as Record<string, unknown>)[CACHE_KEY] = cache;
|
|
53
|
+
writeLock(lock as import("../config/schema.js").LockFile, home);
|
|
54
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing error formatting utilities for the catalog extension.
|
|
3
|
+
*
|
|
4
|
+
* Translates raw system/network errors into actionable messages that
|
|
5
|
+
* guide the user toward a fix (e.g., suggest `ct init --force` for
|
|
6
|
+
* corrupt YAML, or `ct login` for missing gist credentials).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Predicate helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Returns true when the error looks like a YAML parse failure. */
|
|
14
|
+
export function isCorruptYamlError(err: unknown): boolean {
|
|
15
|
+
if (!(err instanceof Error)) return false;
|
|
16
|
+
const msg = err.message ?? "";
|
|
17
|
+
const lower = msg.toLowerCase();
|
|
18
|
+
return (
|
|
19
|
+
msg.includes("YAMLException") ||
|
|
20
|
+
msg.includes("YAML") && msg.includes("line") ||
|
|
21
|
+
lower.includes("unexpected") && lower.includes("stream") ||
|
|
22
|
+
lower.includes("could not") && lower.includes("yaml") ||
|
|
23
|
+
msg.includes("ZodError") ||
|
|
24
|
+
msg.includes("Expected") && msg.includes("received")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns true when the error indicates a missing gist / no remote. */
|
|
29
|
+
export function isMissingGistError(err: unknown): boolean {
|
|
30
|
+
if (!(err instanceof Error)) return false;
|
|
31
|
+
const msg = err.message ?? "";
|
|
32
|
+
return (
|
|
33
|
+
msg.includes("No gist found") ||
|
|
34
|
+
msg.includes("gist not found") ||
|
|
35
|
+
msg.includes("gist was not found")
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns true when the error is network-related. */
|
|
40
|
+
export function isNetworkError(err: unknown): boolean {
|
|
41
|
+
if (!(err instanceof Error)) return false;
|
|
42
|
+
const msg = err.message.toLowerCase();
|
|
43
|
+
return (
|
|
44
|
+
msg.includes("econnrefused") ||
|
|
45
|
+
msg.includes("etimedout") ||
|
|
46
|
+
msg.includes("enotfound") ||
|
|
47
|
+
msg.includes("econnreset") ||
|
|
48
|
+
msg.includes("timed out") ||
|
|
49
|
+
msg.includes("network") ||
|
|
50
|
+
msg.includes("socket hang up") ||
|
|
51
|
+
msg.includes("fetch failed")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Returns true when the error is a filesystem permission error. */
|
|
56
|
+
export function isPermissionError(err: unknown): boolean {
|
|
57
|
+
if (!(err instanceof Error)) return false;
|
|
58
|
+
const msg = err.message ?? "";
|
|
59
|
+
return msg.includes("EACCES") || msg.includes("EPERM");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Auth error detection
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function isAuthError(err: unknown): boolean {
|
|
67
|
+
if (!(err instanceof Error)) return false;
|
|
68
|
+
const msg = err.message ?? "";
|
|
69
|
+
return (
|
|
70
|
+
msg.includes("401") ||
|
|
71
|
+
msg.includes("Unauthorized") ||
|
|
72
|
+
msg.includes("403") ||
|
|
73
|
+
msg.includes("Forbidden")
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// File not found detection
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function isFileNotFoundError(err: unknown): boolean {
|
|
82
|
+
if (!(err instanceof Error)) return false;
|
|
83
|
+
const msg = err.message ?? "";
|
|
84
|
+
return msg.includes("ENOENT");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// formatUserError
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Translate a raw error into a user-friendly message with actionable advice.
|
|
93
|
+
*
|
|
94
|
+
* Returns a string suitable for displaying via `ctx.ui.notify(msg, "error")`.
|
|
95
|
+
*/
|
|
96
|
+
export function formatUserError(err: unknown): string {
|
|
97
|
+
// Handle null/undefined
|
|
98
|
+
if (err == null) {
|
|
99
|
+
return "An unknown error occurred. Please try again or run `ct init` to start fresh.";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle non-Error values
|
|
103
|
+
if (!(err instanceof Error)) {
|
|
104
|
+
return `Error: ${String(err)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const msg = err.message ?? "";
|
|
108
|
+
|
|
109
|
+
// Corrupt / invalid YAML
|
|
110
|
+
if (isCorruptYamlError(err)) {
|
|
111
|
+
return `${msg}\nYour cat.yaml appears to be corrupt or has an invalid format. Run \`ct init --force\` to regenerate it from your installed packages.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Auth errors
|
|
115
|
+
if (isAuthError(err)) {
|
|
116
|
+
return `${msg}\nAuthentication failed. Run \`ct login\` to set up your GitHub credentials.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Missing gist
|
|
120
|
+
if (isMissingGistError(err)) {
|
|
121
|
+
return `${msg}\nNo remote catalog found. Run \`ct login\` to link your GitHub account, then use \`ct sync\` to create a remote gist.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Permission errors
|
|
125
|
+
if (isPermissionError(err)) {
|
|
126
|
+
return `${msg}\nPermission denied writing to the catalog directory. Check the permissions on \`~/.pi/sf/catalog/\` and try again.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Network errors
|
|
130
|
+
if (isNetworkError(err)) {
|
|
131
|
+
if (msg.toLowerCase().includes("timed out")) {
|
|
132
|
+
return `${msg}\nNetwork request timed out. Check your internet connection and retry.`;
|
|
133
|
+
}
|
|
134
|
+
return `${msg}\nA network error occurred. Check your internet connection and retry.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// File not found
|
|
138
|
+
if (isFileNotFoundError(err)) {
|
|
139
|
+
return `${msg}\nA required file was not found. Run \`ct init\` to create a new catalog.`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Generic fallback — always show the message, never a raw stack trace
|
|
143
|
+
return msg;
|
|
144
|
+
}
|