@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
|
@@ -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
|
+
}
|