@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,78 @@
1
+ /**
2
+ * Single source of truth for catalog subcommand definitions.
3
+ *
4
+ * Both `dispatch.ts` (parse-time resolution) and `register.ts` (registration)
5
+ * import from this module, eliminating duplicate subcommand/alias definitions.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface SubcommandDef {
13
+ name: string;
14
+ aliases?: string[];
15
+ description: string;
16
+ }
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Canonical subcommand definitions
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Ordered list of all catalog subcommands with their aliases and descriptions.
24
+ *
25
+ * Adding a new subcommand requires updating ONLY this array; `dispatch.ts`
26
+ * and `register.ts` derive everything they need from it.
27
+ */
28
+ export const SUBCOMMAND_DEFS: readonly SubcommandDef[] = [
29
+ { name: "sync", description: "Sync catalog with remote gist" },
30
+ { name: "init", description: "Initialize a new catalog" },
31
+ { name: "add", aliases: ["a"], description: "Add a package to the catalog" },
32
+ { name: "remove", aliases: ["rm"], description: "Remove a package from the catalog" },
33
+ { name: "toggle", description: "Toggle a package's rating" },
34
+ { name: "disable", description: "Disable a package" },
35
+ { name: "enable", description: "Enable a package" },
36
+ { name: "push", description: "Push catalog to remote gist" },
37
+ { name: "pull", description: "Pull catalog from remote gist" },
38
+ { name: "login", description: "Authenticate with GitHub for sync" },
39
+ { name: "status", description: "Show catalog status" },
40
+ { name: "diff", description: "Show diff between local and remote catalog" },
41
+ { name: "verify", description: "Verify catalog integrity" },
42
+ { name: "profiles", description: "List available profiles" },
43
+ { name: "profile", description: "Show or switch active profile" },
44
+ ] as const;
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Derived helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Ordered list of canonical subcommand names. */
51
+ export function getSubcommandNames(): readonly string[] {
52
+ return SUBCOMMAND_DEFS.map((d) => d.name);
53
+ }
54
+
55
+ /** Resolve a token to its canonical subcommand name, or `undefined`. */
56
+ export function resolveCanonical(token: string): string | undefined {
57
+ for (const def of SUBCOMMAND_DEFS) {
58
+ if (def.name === token) return def.name;
59
+ if (def.aliases?.includes(token)) return def.name;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /**
65
+ * Build a lookup `Map` from subcommand name or alias → canonical name.
66
+ *
67
+ * Useful for registration-time alias mapping.
68
+ */
69
+ export function getAliasMap(): Map<string, string> {
70
+ const map = new Map<string, string>();
71
+ for (const def of SUBCOMMAND_DEFS) {
72
+ map.set(def.name, def.name);
73
+ for (const alias of def.aliases ?? []) {
74
+ map.set(alias, def.name);
75
+ }
76
+ }
77
+ return map;
78
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `ct diff` subcommand implementation.
3
+ *
4
+ * Shows local vs remote gist differences by pulling the remote gist
5
+ * content and comparing it line-by-line with the local cat.yaml.
6
+ */
7
+
8
+ import yaml from "js-yaml";
9
+
10
+ import type { CommandArgs, CommandCtx } from "./types.js";
11
+ import { readCatalog } from "../config/io.js";
12
+ import { readCachedGistId } from "../sync/cache.js";
13
+ import { readGist } from "../sync/gist.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Context for `diffCommand`. Uses the base `CommandCtx`. */
20
+ export type DiffCtx = CommandCtx;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ interface DiffLine {
27
+ type: "added" | "removed" | "unchanged";
28
+ content: string;
29
+ }
30
+
31
+ /**
32
+ * Compute a simple line-by-line diff between two strings.
33
+ * Returns lines marked as added (in remote only), removed (in local only),
34
+ * or unchanged.
35
+ */
36
+ function lineDiff(local: string, remote: string): DiffLine[] {
37
+ const localLines = local.split("\n");
38
+ const remoteLines = remote.split("\n");
39
+
40
+ const localSet = new Map<string, number>();
41
+ for (const line of localLines) {
42
+ localSet.set(line, (localSet.get(line) ?? 0) + 1);
43
+ }
44
+
45
+ const remoteSet = new Map<string, number>();
46
+ for (const line of remoteLines) {
47
+ remoteSet.set(line, (remoteSet.get(line) ?? 0) + 1);
48
+ }
49
+
50
+ // Build a merged set of unique lines preserving order
51
+ const seen = new Set<string>();
52
+ const allLines: string[] = [];
53
+ for (const line of localLines) {
54
+ if (!seen.has(line)) {
55
+ seen.add(line);
56
+ allLines.push(line);
57
+ }
58
+ }
59
+ for (const line of remoteLines) {
60
+ if (!seen.has(line)) {
61
+ seen.add(line);
62
+ allLines.push(line);
63
+ }
64
+ }
65
+
66
+ const result: DiffLine[] = [];
67
+ for (const line of allLines) {
68
+ const localCount = localSet.get(line) ?? 0;
69
+ const remoteCount = remoteSet.get(line) ?? 0;
70
+
71
+ if (line === "") {
72
+ // Skip trailing empty lines in diff output
73
+ continue;
74
+ }
75
+
76
+ if (localCount > 0 && remoteCount > 0) {
77
+ result.push({ type: "unchanged", content: line });
78
+ } else if (localCount > 0) {
79
+ result.push({ type: "removed", content: line });
80
+ } else {
81
+ result.push({ type: "added", content: line });
82
+ }
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // diffCommand
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Execute the `ct diff` subcommand.
94
+ *
95
+ * Fetches the remote gist content and compares it against the local
96
+ * cat.yaml serialization. Shows added/removed lines.
97
+ */
98
+ export async function diffCommand(
99
+ _args: CommandArgs,
100
+ ctx: DiffCtx,
101
+ ): Promise<void> {
102
+ // --- 1. Check for cached gist ID ---
103
+ const gistId = readCachedGistId(ctx.home);
104
+ if (!gistId) {
105
+ ctx.ui.notify(
106
+ `No remote gist configured. Use \`ct sync\` or \`ct push\` first.`,
107
+ "error",
108
+ );
109
+ return;
110
+ }
111
+
112
+ // --- 2. Read local catalog as serialized YAML ---
113
+ const catalog = readCatalog(ctx.home);
114
+ const localYaml = yaml.dump(catalog);
115
+
116
+ // --- 3. Fetch remote gist ---
117
+ let remoteYaml: string;
118
+ try {
119
+ const gist = await readGist(gistId);
120
+ const catFile = gist.files["cat.yaml"];
121
+ remoteYaml = catFile?.content ?? "";
122
+ } catch (err: unknown) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ ctx.ui.notify(`Failed to read remote gist: ${message}`, "error");
125
+ return;
126
+ }
127
+
128
+ // --- 4. Compute diff ---
129
+ if (localYaml === remoteYaml) {
130
+ ctx.ui.notify("Local and remote are identical.", "info");
131
+ return;
132
+ }
133
+
134
+ // If remote is empty, all local lines are additions from remote's perspective
135
+ if (!remoteYaml.trim()) {
136
+ ctx.ui.notify("Remote is empty. Local has content to push.", "info");
137
+ return;
138
+ }
139
+
140
+ const diff = lineDiff(localYaml, remoteYaml);
141
+
142
+ const parts: string[] = ["Local vs Remote diff:"];
143
+ const changed = diff.filter(
144
+ (d) => d.type === "added" || d.type === "removed",
145
+ );
146
+
147
+ if (changed.length === 0) {
148
+ ctx.ui.notify("Local and remote are identical.", "info");
149
+ return;
150
+ }
151
+
152
+ for (const line of changed) {
153
+ const prefix = line.type === "added" ? "+ " : "- ";
154
+ parts.push(`${prefix}${line.content}`);
155
+ }
156
+
157
+ ctx.ui.notify(parts.join("\n"), "info");
158
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Command dispatcher and argument parsing for the `/ct` extension commands.
3
+ *
4
+ * `parseSubcommand` splits a raw argument list (e.g. `["sync", "--force"]`)
5
+ * into a structured `{ subcommand, flags, positional }` object.
6
+ *
7
+ * Subcommand names and aliases are derived from the shared definitions in
8
+ * `definitions.ts` — the single source of truth.
9
+ */
10
+
11
+ import {
12
+ getSubcommandNames,
13
+ resolveCanonical,
14
+ } from "./definitions.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Re-export derived constants for backward compatibility
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Canonical subcommand names accepted by the `/ct` command. */
21
+ export const SUBCOMMANDS = getSubcommandNames() as readonly string[];
22
+
23
+ /** Canonical subcommand name type derived from the definitions. */
24
+ export type SubcommandName = (typeof SUBCOMMANDS)[number];
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // resolveAlias (delegates to shared definitions)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Map a token to its canonical subcommand name.
32
+ *
33
+ * Delegates to `resolveCanonical` from the shared definitions module.
34
+ */
35
+ export function resolveAlias(token: string): SubcommandName | undefined {
36
+ return resolveCanonical(token) as SubcommandName | undefined;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Types
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export interface ParsedCommand {
44
+ /** Canonical subcommand name, or `undefined` when the first token is not
45
+ * a recognized subcommand or alias. */
46
+ subcommand: SubcommandName | undefined;
47
+ /** Parsed flags. Boolean flags are `true`; key=value flags hold the string
48
+ * value (no type coercion beyond that). */
49
+ flags: Record<string, true | string>;
50
+ /** Positional (non-flag) arguments, in order of appearance. */
51
+ positional: string[];
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // parseSubcommand
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Parse the raw argument string array that accompanies a `/ct` invocation.
60
+ *
61
+ * The first token is treated as the subcommand (subject to alias resolution).
62
+ * Tokens starting with `--` are flags:
63
+ * - `--flag` → `{ flag: true }`
64
+ * - `--key=value` → `{ key: "value" }`
65
+ * All other tokens are collected as positional arguments.
66
+ */
67
+ export function parseSubcommand(args: string[]): ParsedCommand {
68
+ const flags: Record<string, true | string> = {};
69
+ const positional: string[] = [];
70
+
71
+ let subcommand: SubcommandName | undefined;
72
+
73
+ for (let i = 0; i < args.length; i++) {
74
+ const token = args[i];
75
+
76
+ // First non-flag token is the subcommand.
77
+ if (subcommand === undefined && !token.startsWith("--")) {
78
+ subcommand = resolveAlias(token);
79
+ continue;
80
+ }
81
+
82
+ // Flag tokens.
83
+ if (token.startsWith("--")) {
84
+ const body = token.slice(2);
85
+
86
+ if (body.includes("=")) {
87
+ const eqIdx = body.indexOf("=");
88
+ const key = body.slice(0, eqIdx);
89
+ const value = body.slice(eqIdx + 1);
90
+ flags[key] = value;
91
+ } else {
92
+ flags[body] = true;
93
+ }
94
+ continue;
95
+ }
96
+
97
+ // Everything else is positional.
98
+ positional.push(token);
99
+ }
100
+
101
+ return { subcommand, flags, positional };
102
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `ct init` command implementation.
3
+ *
4
+ * Scans currently installed pi packages and generates `cat.yaml`.
5
+ * With `--from-gist <id>`, imports catalog content from a public GitHub Gist.
6
+ */
7
+
8
+ import yaml from "js-yaml";
9
+
10
+ import { scanInstalled } from "../catalog/install.js";
11
+ import { CatalogYamlSchema } from "../config/schema.js";
12
+ import type { CatalogYaml } from "../config/schema.js";
13
+ import type { CommandArgs, CommandCtx } from "./types.js";
14
+ import { writeCatalog } from "../config/io.js";
15
+ import { readGist } from "../sync/gist.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Context for `initCommand`. Uses the base `CommandCtx`. */
22
+ export type InitContext = CommandCtx;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // initCommand
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Initialize a new catalog.
30
+ *
31
+ * - Without flags: scans installed packages and generates a catalog with
32
+ * `rating: 'core'` for every discovered package.
33
+ * - With `--from-gist=<id>`: fetches the gist, reads its `cat.yaml` file,
34
+ * validates it, and writes it as the local catalog.
35
+ */
36
+ export async function initCommand(
37
+ args: CommandArgs,
38
+ ctx: InitContext,
39
+ ): Promise<void> {
40
+ const { flags } = args;
41
+
42
+ // --from-gist mode
43
+ const gistId = typeof flags["from-gist"] === "string" ? flags["from-gist"] : undefined;
44
+
45
+ if (gistId) {
46
+ await initFromGist(gistId, ctx);
47
+ return;
48
+ }
49
+
50
+ // Default: scan installed packages
51
+ initFromScan(ctx);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // initFromScan
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function initFromScan(ctx: InitContext): void {
59
+ let installed: Record<string, { source: string }>;
60
+ try {
61
+ installed = scanInstalled(ctx.home);
62
+ } catch (err: unknown) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ ctx.ui.notify(`Failed to scan installed packages: ${message}`, "error");
65
+ return;
66
+ }
67
+ const names = Object.keys(installed);
68
+
69
+ const packages: CatalogYaml["packages"] = {};
70
+ for (const [name, pkg] of Object.entries(installed)) {
71
+ packages[name] = {
72
+ source: pkg.source,
73
+ rating: "core",
74
+ };
75
+ }
76
+
77
+ const catalog: CatalogYaml = {
78
+ meta: { pi_version: "0.0.0" },
79
+ packages,
80
+ };
81
+
82
+ writeCatalog(catalog, ctx.home);
83
+
84
+ ctx.ui.notify(
85
+ `Initialized catalog with ${names.length} package${names.length === 1 ? "" : "s"}.`,
86
+ "info",
87
+ );
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // initFromGist
92
+ // ---------------------------------------------------------------------------
93
+
94
+ async function initFromGist(gistId: string, ctx: InitContext): Promise<void> {
95
+ let gistContent: string;
96
+
97
+ try {
98
+ const gist = await readGist(gistId);
99
+ const catFile = gist.files["cat.yaml"];
100
+
101
+ if (!catFile?.content) {
102
+ ctx.ui.notify(
103
+ `Gist "${gistId}" does not contain a cat.yaml file.`,
104
+ "error",
105
+ );
106
+ return;
107
+ }
108
+
109
+ gistContent = catFile.content;
110
+ } catch (err: unknown) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ ctx.ui.notify(`Failed to fetch gist: ${message}`, "error");
113
+ return;
114
+ }
115
+
116
+ // Validate and write
117
+ const parsed = yaml.load(gistContent);
118
+ const catalog = CatalogYamlSchema.parse(parsed);
119
+
120
+ writeCatalog(catalog, ctx.home);
121
+
122
+ const count = Object.keys(catalog.packages).length;
123
+ ctx.ui.notify(
124
+ `Imported catalog from gist with ${count} package${count === 1 ? "" : "s"}.`,
125
+ "info",
126
+ );
127
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `ct login` subcommand implementation.
3
+ *
4
+ * Authenticates the user via the GitHub CLI (`gh`) and auto-pulls their
5
+ * remote catalog on success.
6
+ *
7
+ * Flow:
8
+ * 1. Detect whether `gh` CLI is installed.
9
+ * 2. Check if the user is authenticated via `gh auth status`.
10
+ * 3. Verify a valid token is available via `getToken()`.
11
+ * 4. Auto-pull the remote catalog on success.
12
+ */
13
+
14
+ import type { CommandArgs, CommandCtx } from "./types.js";
15
+ import { checkAuth, getToken, isGhInstalled } from "../sync/auth.js";
16
+ import { pullCatalog } from "../sync/pull.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Context for `loginCommand`. Uses the base `CommandCtx`. */
23
+ export type LoginCtx = CommandCtx;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // loginCommand
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Execute the `ct login` subcommand.
31
+ *
32
+ * Detects `gh` CLI, checks authentication status, verifies token,
33
+ * and auto-pulls the remote catalog on success.
34
+ */
35
+ export async function loginCommand(
36
+ args: CommandArgs,
37
+ ctx: LoginCtx,
38
+ ): Promise<void> {
39
+ const { flags } = args;
40
+ const profile =
41
+ typeof flags["profile"] === "string" ? flags["profile"] : "default";
42
+
43
+ // --- 1. Detect gh CLI -----------------------------------------------------
44
+ const ghInstalled = await isGhInstalled();
45
+
46
+ if (!ghInstalled) {
47
+ ctx.ui.notify(
48
+ "GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com, then re-run `ct login`.",
49
+ "info",
50
+ );
51
+ return;
52
+ }
53
+
54
+ // --- 2. Check authentication status ----------------------------------------
55
+ const isAuthenticated = await checkAuth();
56
+
57
+ if (!isAuthenticated) {
58
+ ctx.ui.notify(
59
+ "Not authenticated with GitHub. Run the following to log in:\n" +
60
+ " gh auth login\n" +
61
+ "Then re-run `ct login` to connect your catalog.",
62
+ "info",
63
+ );
64
+ return;
65
+ }
66
+
67
+ // --- 3. Verify token ------------------------------------------------------
68
+ const token = await getToken();
69
+
70
+ if (!token) {
71
+ ctx.ui.notify(
72
+ "Authenticated, but no token available. Run `gh auth login` with the `read:gist` scope, then re-run `ct login`.",
73
+ "warning",
74
+ );
75
+ return;
76
+ }
77
+
78
+ // --- 4. Already authenticated — auto-pull ---------------------------------
79
+ ctx.ui.notify("Already authenticated with GitHub.", "info");
80
+
81
+ try {
82
+ await pullCatalog(profile, ctx.home);
83
+ ctx.ui.notify(
84
+ `Login successful. Pulled remote catalog for profile "${profile}".`,
85
+ "info",
86
+ );
87
+ } catch (err: unknown) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+
90
+ // If no gist exists, provide first-time guidance
91
+ if (message.includes("No gist found")) {
92
+ ctx.ui.notify(
93
+ "Login successful, but no remote catalog found. " +
94
+ "Use `ct add` to add packages, then `ct sync` to create and push your catalog.",
95
+ "info",
96
+ );
97
+ return;
98
+ }
99
+
100
+ ctx.ui.notify(
101
+ `Login successful, but pull failed: ${message}`,
102
+ "warning",
103
+ );
104
+ }
105
+ }