@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.
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import yaml from "js-yaml";
3
+
4
+ import { catalogFile, lockFile, ensureCatalogDir } from "./paths.js";
5
+ import { CatalogYamlSchema, LockFileSchema } from "./schema.js";
6
+ import type { CatalogYaml, LockFile } from "./schema.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Empty defaults
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const EMPTY_CATALOG: CatalogYaml = {
13
+ meta: { pi_version: "0.0.0" },
14
+ packages: {},
15
+ };
16
+
17
+ const EMPTY_LOCK: LockFile = { packages: {} };
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // cat.yaml I/O
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Read and parse `cat.yaml`. Returns an empty catalog when the file is
25
+ * missing or empty. Throws on malformed YAML or schema-invalid content.
26
+ */
27
+ export function readCatalog(home?: string): CatalogYaml {
28
+ const filePath = catalogFile(home);
29
+
30
+ if (!fs.existsSync(filePath)) {
31
+ return structuredClone(EMPTY_CATALOG);
32
+ }
33
+
34
+ const raw = fs.readFileSync(filePath, "utf-8");
35
+ if (raw.trim() === "") {
36
+ return structuredClone(EMPTY_CATALOG);
37
+ }
38
+
39
+ const parsed = yaml.load(raw);
40
+ return CatalogYamlSchema.parse(parsed);
41
+ }
42
+
43
+ /**
44
+ * Serialize and write `cat.yaml`, creating the catalog directory if needed.
45
+ */
46
+ export function writeCatalog(catalog: CatalogYaml, home?: string): void {
47
+ ensureCatalogDir(home);
48
+ const filePath = catalogFile(home);
49
+ const content = yaml.dump(catalog);
50
+ fs.writeFileSync(filePath, content, "utf-8");
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // catalog.lock.json I/O
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Read and parse `catalog.lock.json`. Returns an empty lock when the file
59
+ * is missing. Throws on malformed JSON or schema-invalid content.
60
+ */
61
+ export function readLock(home?: string): LockFile {
62
+ const filePath = lockFile(home);
63
+
64
+ if (!fs.existsSync(filePath)) {
65
+ return structuredClone(EMPTY_LOCK);
66
+ }
67
+
68
+ const raw = fs.readFileSync(filePath, "utf-8");
69
+ const parsed = JSON.parse(raw);
70
+ return LockFileSchema.parse(parsed);
71
+ }
72
+
73
+ /**
74
+ * Serialize and write `catalog.lock.json`, creating the catalog directory
75
+ * if needed.
76
+ */
77
+ export function writeLock(lock: LockFile, home?: string): void {
78
+ ensureCatalogDir(home);
79
+ const filePath = lockFile(home);
80
+ const content = JSON.stringify(lock, null, 2) + "\n";
81
+ fs.writeFileSync(filePath, content, "utf-8");
82
+ }
@@ -0,0 +1,44 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { globalDir } from "@pi-stef/paths";
5
+
6
+ /**
7
+ * ~/.pi/sf/catalog/
8
+ */
9
+ export function catalogDir(home?: string): string {
10
+ return globalDir("catalog", home);
11
+ }
12
+
13
+ /**
14
+ * ~/.pi/sf/catalog/cat.yaml
15
+ */
16
+ export function catalogFile(home?: string): string {
17
+ return path.join(catalogDir(home), "cat.yaml");
18
+ }
19
+
20
+ /**
21
+ * ~/.pi/sf/catalog/catalog.lock.json
22
+ */
23
+ export function lockFile(home?: string): string {
24
+ return path.join(catalogDir(home), "catalog.lock.json");
25
+ }
26
+
27
+ /**
28
+ * Creates the catalog directory if it does not already exist.
29
+ */
30
+ export function ensureCatalogDir(home?: string): void {
31
+ const dir = catalogDir(home);
32
+ if (!fs.existsSync(dir)) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * ~/.pi/agent/npm/node_modules
39
+ *
40
+ * Centralised so the npm node_modules path is not hardcoded in consumers.
41
+ */
42
+ export function npmNodeModulesDir(home?: string): string {
43
+ return path.join(home ?? os.homedir(), ".pi", "agent", "npm", "node_modules");
44
+ }
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // cat.yaml schema
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Type discriminator for a catalog package entry. */
8
+ export const PackageType = z.enum(["skill", "pi-native"]);
9
+
10
+ /** Rating values for catalog packages. */
11
+ export const Rating = z.enum(["core", "useful", "debatable", "disabled"]);
12
+
13
+ /** A single package entry inside cat.yaml. */
14
+ export const CatalogPackageSchema = z.object({
15
+ /** Where to fetch the package from (URL, path, etc.). */
16
+ source: z.string().min(1),
17
+ /** User-assigned rating. */
18
+ rating: Rating,
19
+ /** Optional type discriminator. */
20
+ type: PackageType.optional(),
21
+ /** Optional profile name this package belongs to. */
22
+ profile: z.string().optional(),
23
+ /** Whether the package is active. Defaults to true when absent. */
24
+ enabled: z.boolean().optional(),
25
+ /** Previous rating before disable; used by enablePackage to restore. */
26
+ previousRating: Rating.optional(),
27
+ });
28
+
29
+ /** The meta section at the top of cat.yaml. */
30
+ export const CatalogMetaSchema = z.object({
31
+ /** Minimum pi version this catalog requires. */
32
+ pi_version: z.string().min(1),
33
+ /** Currently active profile name. */
34
+ activeProfile: z.string().optional(),
35
+ });
36
+
37
+ /** A single named profile with its own package overrides. */
38
+ export const ProfileSchema = z.object({
39
+ /** Packages specific to this profile (override base packages). */
40
+ packages: z.record(z.string(), CatalogPackageSchema),
41
+ });
42
+
43
+ /** Full cat.yaml document schema. */
44
+ export const CatalogYamlSchema = z.object({
45
+ meta: CatalogMetaSchema,
46
+ packages: z.record(z.string(), CatalogPackageSchema),
47
+ /** Named profiles with package overrides. */
48
+ profiles: z.record(z.string(), ProfileSchema).optional(),
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // catalog.lock.json schema
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Possible sync states for a locked package. */
56
+ export const SyncState = z.enum(["synced", "outdated", "conflict"]);
57
+
58
+ /** A single package entry inside catalog.lock.json. */
59
+ export const LockPackageSchema = z.object({
60
+ /** Installed version string. */
61
+ version: z.string().min(1),
62
+ /** Deterministic hash derived from the source specifier (not installed file contents). */
63
+ sourceHash: z.string().min(1),
64
+ /** ISO-8601 timestamp of when the package was installed. */
65
+ installedAt: z.string().min(1),
66
+ /** Current synchronization state relative to the source. */
67
+ syncState: SyncState,
68
+ });
69
+
70
+ /** Full catalog.lock.json document schema. */
71
+ export const LockFileSchema = z
72
+ .object({
73
+ packages: z.record(z.string(), LockPackageSchema),
74
+ })
75
+ .passthrough();
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Inferred TypeScript types
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export type CatalogYaml = z.infer<typeof CatalogYamlSchema>;
82
+ export type CatalogMeta = z.infer<typeof CatalogMetaSchema>;
83
+ export type CatalogPackage = z.infer<typeof CatalogPackageSchema>;
84
+ export type Profile = z.infer<typeof ProfileSchema>;
85
+ export type LockFile = z.infer<typeof LockFileSchema>;
86
+ export type LockPackage = z.infer<typeof LockPackageSchema>;
87
+ export type RatingValue = z.infer<typeof Rating>;
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ // catalog
2
+ export { scanInstalled, type InstalledPackage, type InstalledMap } from "./catalog/install.js";
3
+ export {
4
+ reconcile,
5
+ executeActions,
6
+ type CatalogEntry,
7
+ type InstallAction,
8
+ type UninstallAction,
9
+ type UpgradeAction,
10
+ type OrphanReport,
11
+ type ReconcilePlan,
12
+ type ReconcileOptions,
13
+ type ActionError,
14
+ type ExecuteResult,
15
+ type ExecuteOptions,
16
+ } from "./catalog/reconcile.js";
17
+ export {
18
+ extractNpmName,
19
+ extractNpmVersion,
20
+ cleanGitName,
21
+ parseSource,
22
+ sourceToKey,
23
+ extractVersionFromSource,
24
+ type ParsedSource,
25
+ } from "./catalog/source.js";
26
+
27
+ // config
28
+ export {
29
+ catalogDir,
30
+ catalogFile,
31
+ lockFile,
32
+ ensureCatalogDir,
33
+ npmNodeModulesDir,
34
+ } from "./config/paths.js";
35
+ export {
36
+ PackageType,
37
+ CatalogPackageSchema,
38
+ CatalogMetaSchema,
39
+ CatalogYamlSchema,
40
+ ProfileSchema,
41
+ SyncState,
42
+ LockPackageSchema,
43
+ LockFileSchema,
44
+ type CatalogYaml,
45
+ type CatalogMeta,
46
+ type CatalogPackage,
47
+ type LockFile,
48
+ type LockPackage,
49
+ } from "./config/schema.js";
50
+
51
+ // sync
52
+ export { checkAuth, getToken } from "./sync/auth.js";
53
+ export {
54
+ createGist,
55
+ readGist,
56
+ updateGist,
57
+ findGistByDescription,
58
+ _resetOctokit,
59
+ type GistFiles,
60
+ type GistResult,
61
+ type GistSummary,
62
+ } from "./sync/gist.js";
63
+ export {
64
+ gistCachePath,
65
+ readCachedGistId,
66
+ writeCachedGistId,
67
+ } from "./sync/cache.js";
68
+ export { pullCatalog, type PullResult } from "./sync/pull.js";
69
+ export { pushCatalog, type PushResult } from "./sync/push.js";
70
+
71
+ // util
72
+ export {
73
+ execCommand,
74
+ piInstall,
75
+ piUninstall,
76
+ ExecError,
77
+ type ExecOptions,
78
+ type ExecResult,
79
+ type PiExecOptions,
80
+ } from "./util/exec.js";
81
+
82
+ // update
83
+ export { checkSelfUpdate } from "./update/self-update.js";
84
+ export { checkPiUpdate } from "./update/pi-update.js";
85
+ export type { UpdateCheckResult, UpdateCacheEntry } from "./update/types.js";
86
+
87
+ // profiles
88
+ export {
89
+ DEFAULT_PROFILE,
90
+ createProfile,
91
+ switchProfile,
92
+ deleteProfile,
93
+ resolveEffectivePackages,
94
+ } from "./profiles/manager.js";
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Profile manager for multi-profile catalog support.
3
+ *
4
+ * Profiles allow different package sets for different machines or contexts
5
+ * (e.g., work vs personal). Each profile has its own package overrides that
6
+ * merge over the base packages.
7
+ */
8
+
9
+ import type { CatalogYaml, CatalogPackage } from "../config/schema.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Name of the default profile (always available). */
16
+ export const DEFAULT_PROFILE = "default";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Internal helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Ensure the catalog has a profiles record, creating one if needed. */
23
+ function ensureProfiles(catalog: CatalogYaml): CatalogYaml {
24
+ if (catalog.profiles === undefined) {
25
+ return { ...catalog, profiles: {} };
26
+ }
27
+ return catalog;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // createProfile
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Create a new empty profile.
36
+ *
37
+ * @throws if a profile with the given name already exists
38
+ */
39
+ export function createProfile(
40
+ catalog: CatalogYaml,
41
+ name: string,
42
+ ): CatalogYaml {
43
+ if (name === DEFAULT_PROFILE) {
44
+ throw new Error(`The "${DEFAULT_PROFILE}" profile always exists`);
45
+ }
46
+
47
+ const withProfiles = ensureProfiles(catalog);
48
+
49
+ if (withProfiles.profiles![name]) {
50
+ throw new Error(`Profile "${name}" already exists`);
51
+ }
52
+
53
+ return {
54
+ ...withProfiles,
55
+ profiles: {
56
+ ...withProfiles.profiles!,
57
+ [name]: { packages: {} },
58
+ },
59
+ };
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // switchProfile
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Switch the active profile.
68
+ *
69
+ * @throws if the target profile does not exist
70
+ */
71
+ export function switchProfile(
72
+ catalog: CatalogYaml,
73
+ name: string,
74
+ ): CatalogYaml {
75
+ if (name !== DEFAULT_PROFILE) {
76
+ if (!catalog.profiles?.[name]) {
77
+ throw new Error(`Profile "${name}" not found`);
78
+ }
79
+ }
80
+
81
+ return {
82
+ ...catalog,
83
+ meta: {
84
+ ...catalog.meta,
85
+ activeProfile: name,
86
+ },
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // deleteProfile
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Delete a profile.
96
+ *
97
+ * @throws if the profile does not exist
98
+ * @throws if attempting to delete the default profile
99
+ */
100
+ export function deleteProfile(
101
+ catalog: CatalogYaml,
102
+ name: string,
103
+ ): CatalogYaml {
104
+ if (name === DEFAULT_PROFILE) {
105
+ throw new Error(`Cannot delete the "${DEFAULT_PROFILE}" profile`);
106
+ }
107
+
108
+ if (!catalog.profiles?.[name]) {
109
+ throw new Error(`Profile "${name}" not found`);
110
+ }
111
+
112
+ const { [name]: _, ...remainingProfiles } = catalog.profiles;
113
+
114
+ // Clear activeProfile if it was the deleted profile
115
+ const meta = { ...catalog.meta };
116
+ if (meta.activeProfile === name) {
117
+ delete meta.activeProfile;
118
+ }
119
+
120
+ return {
121
+ ...catalog,
122
+ meta,
123
+ profiles: remainingProfiles,
124
+ };
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // resolveEffectivePackages
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Resolve the effective package set by merging base packages with
133
+ * the active (or specified) profile's overrides.
134
+ *
135
+ * Profile packages override base packages with the same key.
136
+ * If no profile is specified, uses `meta.activeProfile` (falling back
137
+ * to `DEFAULT_PROFILE`).
138
+ */
139
+ export function resolveEffectivePackages(
140
+ catalog: CatalogYaml,
141
+ profile?: string,
142
+ ): Record<string, CatalogPackage> {
143
+ const profileName = profile ?? catalog.meta.activeProfile ?? DEFAULT_PROFILE;
144
+
145
+ // Start with a shallow clone of base packages
146
+ const result: Record<string, CatalogPackage> = { ...catalog.packages };
147
+
148
+ // Merge profile overrides on top
149
+ if (profileName !== DEFAULT_PROFILE) {
150
+ const profilePkgs = catalog.profiles?.[profileName]?.packages;
151
+ if (profilePkgs) {
152
+ for (const [key, value] of Object.entries(profilePkgs)) {
153
+ result[key] = value;
154
+ }
155
+ }
156
+ }
157
+
158
+ return result;
159
+ }