my-patina 0.1.1 → 0.2.0

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.
Files changed (2) hide show
  1. package/dist/cli.js +341 -250
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // src/wizard.ts
4
4
  import * as p from "@clack/prompts";
5
5
  import chalk from "chalk";
6
- import { dirname as dirname3, join as join5, resolve } from "path";
7
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
6
+ import { dirname as dirname4, join as join7, resolve } from "path";
7
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
8
8
  import yaml3 from "js-yaml";
9
9
 
10
10
  // src/detect.ts
@@ -20,9 +20,8 @@ function loadProfile(root) {
20
20
  }
21
21
 
22
22
  // src/scaffold.ts
23
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync3 } from "fs";
24
- import { join as join3, dirname as dirname2 } from "path";
25
- import { fileURLToPath } from "url";
23
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
24
+ import { join as join5, dirname as dirname3 } from "path";
26
25
  import yaml2 from "js-yaml";
27
26
 
28
27
  // src/template.ts
@@ -34,65 +33,139 @@ function render(template, vars) {
34
33
 
35
34
  // src/upgrade.ts
36
35
  import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
37
- import { join as join2, dirname } from "path";
36
+ import { join as join3, dirname as dirname2 } from "path";
38
37
 
39
38
  // src/checksums.ts
40
39
  import { createHash } from "crypto";
41
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
40
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
41
+
42
+ // src/template-loader.ts
43
+ import { readFileSync as readFileSync2 } from "fs";
44
+ import { join as join2, dirname } from "path";
45
+ import { fileURLToPath } from "url";
46
+ var __dirname = dirname(fileURLToPath(import.meta.url));
47
+ var TEMPLATES_DIR = join2(__dirname, "templates");
48
+ function tpl(relativePath) {
49
+ return readFileSync2(join2(TEMPLATES_DIR, relativePath), "utf8");
50
+ }
51
+
52
+ // src/modules/linkedin/index.ts
53
+ var LI_COMMANDS = [
54
+ "li-all.md",
55
+ "li-about.md",
56
+ "li-headline.md",
57
+ "li-experience.md",
58
+ "li-skills.md",
59
+ "li-featured.md",
60
+ "li-activity.md"
61
+ ];
62
+ var LI_MANAGED_PATHS = [
63
+ ...LI_COMMANDS.map((c) => `.claude/commands/${c}`),
64
+ ".claude/modules/linkedin/manifest.md"
65
+ ];
66
+ var CONTENT_FILE_NAMES = [
67
+ "INSTRUCTIONS.md",
68
+ "LinkedIn Current State.md",
69
+ "LinkedIn About.md",
70
+ "LinkedIn Headline.md",
71
+ "LinkedIn Experience.md",
72
+ "LinkedIn Skills.md",
73
+ "LinkedIn Featured.md",
74
+ "LinkedIn Activity.md"
75
+ ];
76
+ var linkedinModule = {
77
+ id: "linkedin",
78
+ label: "LinkedIn",
79
+ hint: "draft and refine your LinkedIn profile",
80
+ managedPaths: LI_MANAGED_PATHS,
81
+ contentFileNames: CONTENT_FILE_NAMES,
82
+ managedFiles(vars) {
83
+ const files = LI_COMMANDS.map((cmd2) => [
84
+ `.claude/commands/${cmd2}`,
85
+ render(tpl(`modules/linkedin/commands/${cmd2}`), vars)
86
+ ]);
87
+ files.push([
88
+ ".claude/modules/linkedin/manifest.md",
89
+ render(tpl("modules/linkedin/manifest.md"), vars)
90
+ ]);
91
+ return files;
92
+ },
93
+ contentFiles(vars, contentDir) {
94
+ return CONTENT_FILE_NAMES.map((file) => [
95
+ `${contentDir}/linkedin/${file}`,
96
+ render(tpl(`modules/linkedin/graph/${file}`), vars)
97
+ ]);
98
+ },
99
+ onAdd(profile, inputs) {
100
+ if (!profile.linkedin?.profile_url && inputs.liProfileUrl?.trim()) {
101
+ return { ...profile, linkedin: { profile_url: inputs.liProfileUrl.trim() } };
102
+ }
103
+ return profile;
104
+ },
105
+ onRemove(profile) {
106
+ const updated = { ...profile };
107
+ delete updated.linkedin;
108
+ return updated;
109
+ }
110
+ };
111
+
112
+ // src/modules/resume/index.ts
113
+ var RESUME_MANAGED_PATHS = [
114
+ ".claude/commands/resume-refresh.md",
115
+ ".claude/modules/resume/manifest.md"
116
+ ];
117
+ var CONTENT_FILE_NAMES2 = [
118
+ "INSTRUCTIONS.md",
119
+ "Resume Working Draft.md",
120
+ "Resume Last Submitted.md"
121
+ ];
122
+ var resumeModule = {
123
+ id: "resume",
124
+ label: "Resume",
125
+ hint: "keep your resume current from your graph",
126
+ managedPaths: RESUME_MANAGED_PATHS,
127
+ contentFileNames: CONTENT_FILE_NAMES2,
128
+ managedFiles(vars) {
129
+ const [commandPath, manifestPath] = RESUME_MANAGED_PATHS;
130
+ return [
131
+ [commandPath, render(tpl("modules/resume/commands/resume-refresh.md"), vars)],
132
+ [manifestPath, render(tpl("modules/resume/manifest.md"), vars)]
133
+ ];
134
+ },
135
+ contentFiles(vars, contentDir) {
136
+ return CONTENT_FILE_NAMES2.map((file) => [
137
+ `${contentDir}/resume/${file}`,
138
+ render(tpl(`modules/resume/graph/${file}`), vars)
139
+ ]);
140
+ }
141
+ // Resume has no module-specific profile fields — no onAdd/onRemove needed.
142
+ };
143
+
144
+ // src/modules/registry.ts
145
+ var MODULES = [linkedinModule, resumeModule];
146
+ var BY_ID = new Map(MODULES.map((m) => [m.id, m]));
147
+ function getModule(id) {
148
+ return BY_ID.get(id);
149
+ }
150
+
151
+ // src/checksums.ts
42
152
  function hashContent(content) {
43
153
  return createHash("sha256").update(content).digest("hex").slice(0, 16);
44
154
  }
45
155
  function hashFile(filePath) {
46
156
  if (!existsSync2(filePath)) return null;
47
- return hashContent(readFileSync2(filePath, "utf8"));
157
+ return hashContent(readFileSync3(filePath, "utf8"));
48
158
  }
49
159
  var CONTENT_SUBDIRS = ["notes", "skills", "posts"];
50
- var LINKEDIN_MANAGED_FILES = [
51
- ".claude/commands/li-all.md",
52
- ".claude/commands/li-about.md",
53
- ".claude/commands/li-headline.md",
54
- ".claude/commands/li-experience.md",
55
- ".claude/commands/li-skills.md",
56
- ".claude/commands/li-featured.md",
57
- ".claude/commands/li-activity.md"
58
- ];
59
- var RESUME_MANAGED_FILES = [
60
- ".claude/commands/resume-refresh.md"
61
- ];
62
- var MODULE_MANAGED_FILES = {
63
- linkedin: [
64
- ...LINKEDIN_MANAGED_FILES,
65
- ".claude/modules/linkedin/manifest.md"
66
- ],
67
- resume: [
68
- ...RESUME_MANAGED_FILES,
69
- ".claude/modules/resume/manifest.md"
70
- ]
71
- };
72
- var MODULE_CONTENT_FILES = {
73
- linkedin: [
74
- "INSTRUCTIONS.md",
75
- "LinkedIn Current State.md",
76
- "LinkedIn About.md",
77
- "LinkedIn Headline.md",
78
- "LinkedIn Experience.md",
79
- "LinkedIn Skills.md",
80
- "LinkedIn Featured.md",
81
- "LinkedIn Activity.md"
82
- ],
83
- resume: [
84
- "INSTRUCTIONS.md",
85
- "Resume Working Draft.md",
86
- "Resume Last Submitted.md"
87
- ]
88
- };
160
+ var MODULE_MANAGED_FILES = Object.fromEntries(MODULES.map((m) => [m.id, m.managedPaths]));
161
+ var MODULE_CONTENT_FILES = Object.fromEntries(MODULES.map((m) => [m.id, m.contentFileNames]));
89
162
 
90
163
  // src/upgrade.ts
91
164
  function writeManagedFile(targetDir, relativePath, newContent, storedChecksums) {
92
- const fullPath = join2(targetDir, relativePath);
165
+ const fullPath = join3(targetDir, relativePath);
93
166
  const newChecksum = hashContent(newContent);
94
167
  if (!existsSync3(fullPath)) {
95
- mkdirSync(dirname(fullPath), { recursive: true });
168
+ mkdirSync(dirname2(fullPath), { recursive: true });
96
169
  writeFileSync(fullPath, newContent, "utf8");
97
170
  return { outcome: "added", checksum: newChecksum };
98
171
  }
@@ -105,21 +178,66 @@ function writeManagedFile(targetDir, relativePath, newContent, storedChecksums)
105
178
  return { outcome: "skipped", checksum: storedHash };
106
179
  }
107
180
 
108
- // src/scaffold.ts
109
- var __dirname = dirname2(fileURLToPath(import.meta.url));
110
- var TEMPLATES_DIR = join3(__dirname, "templates");
111
- function tpl(relativePath) {
112
- return readFileSync3(join3(TEMPLATES_DIR, relativePath), "utf8");
181
+ // src/state.ts
182
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
183
+ import { join as join4 } from "path";
184
+ var STATE_FILENAME = ".patina-state.json";
185
+ function normalizeChecksums(checksums) {
186
+ const result = {};
187
+ for (const [key, value] of Object.entries(checksums)) {
188
+ result[key.replace(/\\/g, "/")] = value;
189
+ }
190
+ return result;
191
+ }
192
+ function readState(root, profile) {
193
+ const statePath = join4(root, STATE_FILENAME);
194
+ if (existsSync4(statePath)) {
195
+ const raw = readFileSync4(statePath, "utf8");
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(raw);
199
+ } catch {
200
+ throw new Error(
201
+ `Corrupt ${STATE_FILENAME}: failed to parse JSON. Fix or delete the file at ${statePath} and try again.`
202
+ );
203
+ }
204
+ const obj = parsed;
205
+ const rawChecksums = obj.checksums;
206
+ if (rawChecksums !== void 0 && (typeof rawChecksums !== "object" || Array.isArray(rawChecksums) || rawChecksums === null)) {
207
+ throw new Error(`Corrupt ${STATE_FILENAME}: 'checksums' must be an object at ${statePath}.`);
208
+ }
209
+ const checksums2 = normalizeChecksums(rawChecksums ?? {});
210
+ return { checksums: checksums2 };
211
+ }
212
+ const legacyChecksums = profile?._checksums;
213
+ const checksums = normalizeChecksums(legacyChecksums ?? {});
214
+ return { checksums };
215
+ }
216
+ function stripLegacyChecksums(profile) {
217
+ const { _checksums: _stripped, ...clean } = profile;
218
+ return clean;
219
+ }
220
+ function writeState(root, state) {
221
+ const normalized = {
222
+ checksums: normalizeChecksums(state.checksums)
223
+ };
224
+ writeFileSync2(
225
+ join4(root, STATE_FILENAME),
226
+ JSON.stringify(normalized, null, 2) + "\n",
227
+ "utf8"
228
+ );
113
229
  }
230
+
231
+ // src/scaffold.ts
114
232
  function writeRaw(targetDir, relativePath, content) {
115
- const full = join3(targetDir, relativePath);
116
- mkdirSync2(dirname2(full), { recursive: true });
117
- writeFileSync2(full, content, "utf8");
233
+ const full = join5(targetDir, relativePath);
234
+ mkdirSync2(dirname3(full), { recursive: true });
235
+ writeFileSync3(full, content, "utf8");
118
236
  }
119
237
  function touch(targetDir, relativePath) {
120
- const full = join3(targetDir, relativePath);
121
- mkdirSync2(dirname2(full), { recursive: true });
122
- writeFileSync2(full, "", "utf8");
238
+ const full = join5(targetDir, relativePath);
239
+ mkdirSync2(dirname3(full), { recursive: true });
240
+ writeFileSync3(full, "", "utf8");
123
241
  }
124
242
  function profileToVars(profile, liProfileUrl) {
125
243
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
@@ -148,7 +266,7 @@ function baseManagedFiles(vars, editor, targetDir) {
148
266
  mcpServers: {
149
267
  obsidian: {
150
268
  command: "npx",
151
- args: ["-y", "mcp-obsidian@latest", join3(targetDir, vars.CONTENT_DIR).replace(/\\/g, "/")]
269
+ args: ["-y", "mcp-obsidian@latest", join5(targetDir, vars.CONTENT_DIR).replace(/\\/g, "/")]
152
270
  }
153
271
  }
154
272
  };
@@ -157,50 +275,10 @@ function baseManagedFiles(vars, editor, targetDir) {
157
275
  return files;
158
276
  }
159
277
  function moduleManagedFiles(module, vars) {
160
- if (module === "linkedin") {
161
- const liCmds = [
162
- "li-all.md",
163
- "li-about.md",
164
- "li-headline.md",
165
- "li-experience.md",
166
- "li-skills.md",
167
- "li-featured.md",
168
- "li-activity.md"
169
- ];
170
- const files = liCmds.map((cmd2) => [
171
- `.claude/commands/${cmd2}`,
172
- render(tpl(`modules/linkedin/commands/${cmd2}`), vars)
173
- ]);
174
- files.push([
175
- ".claude/modules/linkedin/manifest.md",
176
- render(tpl("modules/linkedin/manifest.md"), vars)
177
- ]);
178
- return files;
179
- }
180
- if (module === "resume") {
181
- return [
182
- [".claude/commands/resume-refresh.md", render(tpl("modules/resume/commands/resume-refresh.md"), vars)],
183
- [".claude/modules/resume/manifest.md", render(tpl("modules/resume/manifest.md"), vars)]
184
- ];
185
- }
186
- return [];
278
+ return getModule(module)?.managedFiles(vars) ?? [];
187
279
  }
188
280
  function moduleContentFiles(module, vars, contentDir) {
189
- if (module === "linkedin") {
190
- const files = MODULE_CONTENT_FILES["linkedin"] ?? [];
191
- return files.map((file) => [
192
- `${contentDir}/linkedin/${file}`,
193
- render(tpl(`modules/linkedin/graph/${file}`), vars)
194
- ]);
195
- }
196
- if (module === "resume") {
197
- const files = MODULE_CONTENT_FILES["resume"] ?? [];
198
- return files.map((file) => [
199
- `${contentDir}/resume/${file}`,
200
- render(tpl(`modules/resume/graph/${file}`), vars)
201
- ]);
202
- }
203
- return [];
281
+ return getModule(module)?.contentFiles(vars, contentDir) ?? [];
204
282
  }
205
283
  async function scaffold(opts) {
206
284
  const {
@@ -263,28 +341,28 @@ async function scaffold(opts) {
263
341
  writeRaw(targetDir, relativePath, content);
264
342
  }
265
343
  }
266
- const profile = {
267
- ...tempProfile,
268
- _checksums: checksums
269
- };
270
- writeRaw(targetDir, "profile.yaml", yaml2.dump(profile));
271
- writeRaw(targetDir, ".gitignore", ".obsidian/\n.DS_Store\n");
344
+ writeRaw(targetDir, "profile.yaml", yaml2.dump(tempProfile));
345
+ writeState(targetDir, { checksums });
346
+ writeRaw(targetDir, ".gitignore", `.obsidian/
347
+ .DS_Store
348
+ ${STATE_FILENAME}
349
+ `);
272
350
  }
273
351
 
274
352
  // src/validate.ts
275
- import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync } from "fs";
276
- import { join as join4, relative, sep, basename } from "path";
353
+ import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync } from "fs";
354
+ import { join as join6, relative, sep, basename } from "path";
277
355
  var NOTES = CONTENT_SUBDIRS[0];
278
356
  var SKILLS = CONTENT_SUBDIRS[1];
279
357
  var POSTS = CONTENT_SUBDIRS[2];
280
358
  function findPatinaRoot(cwd) {
281
- return existsSync4(join4(cwd, "profile.yaml")) ? cwd : null;
359
+ return existsSync5(join6(cwd, "profile.yaml")) ? cwd : null;
282
360
  }
283
361
  function listMarkdownFiles(dir) {
284
- if (!existsSync4(dir)) return [];
362
+ if (!existsSync5(dir)) return [];
285
363
  const results = [];
286
364
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
287
- const fullPath = join4(dir, entry.name);
365
+ const fullPath = join6(dir, entry.name);
288
366
  if (entry.isDirectory()) {
289
367
  results.push(...listMarkdownFiles(fullPath));
290
368
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -337,14 +415,14 @@ function parseExclusions(markdown) {
337
415
  return [...new Set(items)];
338
416
  }
339
417
  function checkSkillNotes(root, profile) {
340
- const contentDir = join4(root, profile.content_dir ?? "graph");
341
- const notesDir = join4(contentDir, NOTES);
342
- const skillsDir = join4(contentDir, SKILLS);
418
+ const contentDir = join6(root, profile.content_dir ?? "graph");
419
+ const notesDir = join6(contentDir, NOTES);
420
+ const skillsDir = join6(contentDir, SKILLS);
343
421
  const noteFiles = listMarkdownFiles(notesDir);
344
422
  const noteSlugs = new Set(noteFiles.map((f) => basename(f, ".md")));
345
423
  const issues = [];
346
424
  for (const skillFile of listMarkdownFiles(skillsDir)) {
347
- const content = readFileSync4(skillFile, "utf8");
425
+ const content = readFileSync5(skillFile, "utf8");
348
426
  const links = extractWikiLinks(content);
349
427
  for (const { target, line } of links) {
350
428
  if (!noteSlugs.has(target)) {
@@ -360,9 +438,9 @@ function checkSkillNotes(root, profile) {
360
438
  return issues;
361
439
  }
362
440
  function checkWikiLinks(root, profile) {
363
- const contentDir = join4(root, profile.content_dir ?? "graph");
364
- const notesDir = join4(contentDir, NOTES);
365
- const postsDir = join4(contentDir, POSTS);
441
+ const contentDir = join6(root, profile.content_dir ?? "graph");
442
+ const notesDir = join6(contentDir, NOTES);
443
+ const postsDir = join6(contentDir, POSTS);
366
444
  const noteFiles = listMarkdownFiles(notesDir);
367
445
  const noteSlugs = new Set(noteFiles.map((f) => basename(f, ".md")));
368
446
  const issues = [];
@@ -371,7 +449,7 @@ function checkWikiLinks(root, profile) {
371
449
  ...listMarkdownFiles(postsDir)
372
450
  ];
373
451
  for (const file of filesToScan) {
374
- const content = readFileSync4(file, "utf8");
452
+ const content = readFileSync5(file, "utf8");
375
453
  const links = extractWikiLinks(content);
376
454
  for (const { target, line } of links) {
377
455
  if (!noteSlugs.has(target)) {
@@ -387,15 +465,15 @@ function checkWikiLinks(root, profile) {
387
465
  return issues;
388
466
  }
389
467
  function checkExclusions(root, profile) {
390
- const contentDir = join4(root, profile.content_dir ?? "graph");
391
- const notesDir = join4(contentDir, NOTES);
392
- const exclusionsPath = join4(notesDir, "exclusions.md");
393
- if (!existsSync4(exclusionsPath)) return [];
394
- const content = readFileSync4(exclusionsPath, "utf8");
468
+ const contentDir = join6(root, profile.content_dir ?? "graph");
469
+ const notesDir = join6(contentDir, NOTES);
470
+ const exclusionsPath = join6(notesDir, "exclusions.md");
471
+ if (!existsSync5(exclusionsPath)) return [];
472
+ const content = readFileSync5(exclusionsPath, "utf8");
395
473
  const items = parseExclusions(content);
396
474
  if (items.length === 0) return [];
397
- const skillsDir = join4(contentDir, SKILLS);
398
- const postsDir = join4(contentDir, POSTS);
475
+ const skillsDir = join6(contentDir, SKILLS);
476
+ const postsDir = join6(contentDir, POSTS);
399
477
  const filesToScan = [
400
478
  ...listMarkdownFiles(skillsDir),
401
479
  ...listMarkdownFiles(postsDir)
@@ -403,7 +481,7 @@ function checkExclusions(root, profile) {
403
481
  const issues = [];
404
482
  const seen = /* @__PURE__ */ new Set();
405
483
  for (const file of filesToScan) {
406
- const fileContent = readFileSync4(file, "utf8");
484
+ const fileContent = readFileSync5(file, "utf8");
407
485
  const lines = fileContent.split("\n");
408
486
  for (let i = 0; i < lines.length; i++) {
409
487
  const lineText = lines[i];
@@ -436,11 +514,11 @@ function validate(root, profile) {
436
514
  if (a.file > b.file) return 1;
437
515
  return (a.line ?? 0) - (b.line ?? 0);
438
516
  });
439
- const contentDir = join4(root, profile.content_dir ?? "graph");
517
+ const contentDir = join6(root, profile.content_dir ?? "graph");
440
518
  const scannedFiles = /* @__PURE__ */ new Set([
441
- ...listMarkdownFiles(join4(contentDir, NOTES)),
442
- ...listMarkdownFiles(join4(contentDir, SKILLS)),
443
- ...listMarkdownFiles(join4(contentDir, POSTS))
519
+ ...listMarkdownFiles(join6(contentDir, NOTES)),
520
+ ...listMarkdownFiles(join6(contentDir, SKILLS)),
521
+ ...listMarkdownFiles(join6(contentDir, POSTS))
444
522
  ]);
445
523
  return {
446
524
  ok: allIssues.length === 0,
@@ -574,18 +652,11 @@ async function runInstall(cwd) {
574
652
  }),
575
653
  modules: () => p.multiselect({
576
654
  message: `Which modules do you want to add?${MULTISELECT_HINT}`,
577
- options: [
578
- {
579
- value: "linkedin",
580
- label: "LinkedIn",
581
- hint: chalk.hex("#64748B")("draft and refine your LinkedIn profile")
582
- },
583
- {
584
- value: "resume",
585
- label: "Resume",
586
- hint: chalk.hex("#64748B")("keep your resume current from your graph")
587
- }
588
- ],
655
+ options: MODULES.map((m) => ({
656
+ value: m.id,
657
+ label: m.label,
658
+ hint: chalk.hex("#64748B")(m.hint)
659
+ })),
589
660
  required: false
590
661
  })
591
662
  },
@@ -641,12 +712,12 @@ async function runInstall(cwd) {
641
712
  p.outro(chalk.hex("#94A3B8")("Run claude from inside your patina to get started."));
642
713
  }
643
714
  function writeProfile(cwd, profile) {
644
- const full = join5(cwd, "profile.yaml");
645
- writeFileSync3(full, yaml3.dump(profile), "utf8");
715
+ const full = join7(cwd, "profile.yaml");
716
+ writeFileSync4(full, yaml3.dump(profile), "utf8");
646
717
  }
647
718
  function removeManagedFileIfUnmodified(targetDir, rel, stored) {
648
- const fullPath = join5(targetDir, rel);
649
- if (!existsSync5(fullPath)) return "deleted";
719
+ const fullPath = join7(targetDir, rel);
720
+ if (!existsSync6(fullPath)) return "deleted";
650
721
  const currentHash = hashFile(fullPath);
651
722
  const storedHash = stored[rel];
652
723
  if (storedHash && currentHash !== storedHash) {
@@ -688,6 +759,48 @@ async function runUpdate(cwd) {
688
759
  await runValidate(cwd, profile);
689
760
  }
690
761
  }
762
+ function applyProfileUpdate(cwd, profile, fields) {
763
+ const updatedProfile = {
764
+ ...profile,
765
+ name: fields.name.trim(),
766
+ title: fields.title.trim(),
767
+ role_description: fields.roleDescription.trim() || void 0,
768
+ job_description_url: fields.jobDescriptionUrl.trim() || void 0,
769
+ work: {
770
+ self_employed: fields.selfEmployed,
771
+ company_name: fields.companyName.trim() || (fields.selfEmployed ? "Freelance" : ""),
772
+ website: fields.website.trim() || void 0,
773
+ company_description: fields.companyDescription.trim() || void 0
774
+ }
775
+ };
776
+ const vars = profileToVars(updatedProfile);
777
+ const stored = readState(cwd, profile).checksums;
778
+ const newChecksums = {};
779
+ const files = [
780
+ ...baseManagedFiles(vars, updatedProfile.editor, cwd),
781
+ ...updatedProfile.modules.flatMap((m) => moduleManagedFiles(m, vars))
782
+ ];
783
+ const updated = [];
784
+ const skipped = [];
785
+ for (const [rel, content] of files) {
786
+ const { outcome, checksum } = writeManagedFile(cwd, rel, content, stored);
787
+ newChecksums[rel] = checksum;
788
+ if (outcome === "skipped") {
789
+ skipped.push(rel);
790
+ } else {
791
+ updated.push(rel);
792
+ }
793
+ }
794
+ for (const [rel, hash] of Object.entries(stored)) {
795
+ if (!(rel in newChecksums)) {
796
+ newChecksums[rel] = hash;
797
+ }
798
+ }
799
+ writeState(cwd, { checksums: newChecksums });
800
+ const profileToWrite = stripLegacyChecksums(updatedProfile);
801
+ writeProfile(cwd, profileToWrite);
802
+ return { profile: profileToWrite, updated, skipped };
803
+ }
691
804
  async function runUpdateProfile(cwd, profile) {
692
805
  console.log("");
693
806
  console.log(` ${label("Update personal info")}`);
@@ -739,44 +852,16 @@ async function runUpdateProfile(cwd, profile) {
739
852
  },
740
853
  { onCancel }
741
854
  );
742
- const updatedProfile = {
743
- ...profile,
744
- name: identity.name.trim(),
745
- title: (identity.title ?? "").trim(),
746
- role_description: (identity.roleDescription ?? "").trim() || void 0,
747
- job_description_url: (identity.jobDescriptionUrl ?? "").trim() || void 0,
748
- work: {
749
- self_employed: selfEmployed,
750
- company_name: work.companyName?.trim() || (selfEmployed ? "Freelance" : ""),
751
- website: work.website?.trim() || void 0,
752
- company_description: work.companyDescription?.trim() || void 0
753
- }
754
- };
755
- const vars = profileToVars(updatedProfile);
756
- const stored = profile._checksums ?? {};
757
- const newChecksums = {};
758
- const files = [
759
- ...baseManagedFiles(vars, updatedProfile.editor, cwd),
760
- ...updatedProfile.modules.flatMap((m) => moduleManagedFiles(m, vars))
761
- ];
762
- const updated = [];
763
- const skipped = [];
764
- for (const [rel, content] of files) {
765
- const { outcome, checksum } = writeManagedFile(cwd, rel, content, stored);
766
- newChecksums[rel] = checksum;
767
- if (outcome === "skipped") {
768
- skipped.push(rel);
769
- } else {
770
- updated.push(rel);
771
- }
772
- }
773
- for (const [rel, hash] of Object.entries(stored)) {
774
- if (!(rel in newChecksums)) {
775
- newChecksums[rel] = hash;
776
- }
777
- }
778
- updatedProfile._checksums = newChecksums;
779
- writeProfile(cwd, updatedProfile);
855
+ const { updated, skipped } = applyProfileUpdate(cwd, profile, {
856
+ name: identity.name,
857
+ title: identity.title ?? "",
858
+ roleDescription: identity.roleDescription ?? "",
859
+ jobDescriptionUrl: identity.jobDescriptionUrl ?? "",
860
+ selfEmployed,
861
+ companyName: work.companyName ?? "",
862
+ website: work.website ?? "",
863
+ companyDescription: work.companyDescription ?? ""
864
+ });
780
865
  const summaryLines = [];
781
866
  if (updated.length > 0) {
782
867
  summaryLines.push(chalk.hex("#94A3B8")(`Updated: ${updated.join(", ")}`));
@@ -787,96 +872,102 @@ async function runUpdateProfile(cwd, profile) {
787
872
  p.note(summaryLines.join("\n"), label("Done"));
788
873
  p.outro(chalk.hex("#94A3B8")("Profile updated."));
789
874
  }
790
- async function runUpdateModules(cwd, profile) {
791
- const currentModules = profile.modules ?? [];
792
- const selected = await p.multiselect({
793
- message: `Which modules do you want active?${MULTISELECT_HINT}`,
794
- options: [
795
- {
796
- value: "linkedin",
797
- label: "LinkedIn",
798
- hint: chalk.hex("#64748B")("draft and refine your LinkedIn profile")
799
- },
800
- {
801
- value: "resume",
802
- label: "Resume",
803
- hint: chalk.hex("#64748B")("keep your resume current from your graph")
804
- }
805
- ],
806
- initialValues: currentModules,
807
- required: false
808
- });
809
- if (p.isCancel(selected)) {
810
- p.cancel(chalk.hex("#94A3B8")("No changes made."));
811
- return;
812
- }
813
- const selectedModules = Array.isArray(selected) ? selected : [];
814
- const toAdd = selectedModules.filter((m) => !currentModules.includes(m));
815
- const toRemove = currentModules.filter((m) => !selectedModules.includes(m));
816
- if (toAdd.length === 0 && toRemove.length === 0) {
817
- p.outro(chalk.hex("#94A3B8")("No changes \u2014 modules unchanged."));
818
- return;
819
- }
820
- const stored = profile._checksums ?? {};
875
+ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
876
+ const stored = readState(cwd, profile).checksums;
821
877
  const newChecksums = { ...stored };
822
- const updatedProfile = { ...profile, modules: [...currentModules] };
823
- const addedFiles = [];
878
+ let updatedProfile = { ...profile, modules: [...profile.modules ?? []] };
879
+ const added = [];
824
880
  const skippedFiles = [];
825
- const deletedFiles = [];
826
- const keptFiles = [];
881
+ const deleted = [];
882
+ const kept = [];
827
883
  for (const module of toAdd) {
828
- if (module === "linkedin" && !updatedProfile.linkedin?.profile_url) {
829
- const url = await p.text({
830
- message: "What's your LinkedIn profile URL?",
831
- placeholder: "https://linkedin.com/in/yourname (optional)"
832
- });
833
- if (p.isCancel(url)) {
834
- p.cancel(chalk.hex("#94A3B8")("No changes made."));
835
- return;
836
- }
837
- if (typeof url === "string" && url.trim()) {
838
- updatedProfile.linkedin = { profile_url: url.trim() };
839
- }
884
+ const def = getModule(module);
885
+ if (def?.onAdd) {
886
+ updatedProfile = def.onAdd(updatedProfile, moduleInputs?.[module] ?? {});
840
887
  }
841
888
  const vars = profileToVars(updatedProfile);
842
889
  const contentDir = updatedProfile.content_dir;
843
890
  for (const [rel, content] of moduleManagedFiles(module, vars)) {
844
- const { outcome, checksum } = writeManagedFile(cwd, rel, content, stored);
891
+ const { outcome, checksum } = writeManagedFile(cwd, rel, content, newChecksums);
845
892
  newChecksums[rel] = checksum;
846
893
  if (outcome === "skipped") {
847
894
  skippedFiles.push(rel);
848
895
  } else {
849
- addedFiles.push(rel);
896
+ added.push(rel);
850
897
  }
851
898
  }
852
899
  for (const [relativePath, content] of moduleContentFiles(module, vars, contentDir)) {
853
- const fullPath = join5(cwd, relativePath);
854
- if (!existsSync5(fullPath)) {
855
- mkdirSync3(dirname3(fullPath), { recursive: true });
856
- writeFileSync3(fullPath, content, "utf8");
857
- addedFiles.push(relativePath);
900
+ const fullPath = join7(cwd, relativePath);
901
+ if (!existsSync6(fullPath)) {
902
+ mkdirSync3(dirname4(fullPath), { recursive: true });
903
+ writeFileSync4(fullPath, content, "utf8");
904
+ added.push(relativePath);
858
905
  }
859
906
  }
860
- updatedProfile.modules = [...updatedProfile.modules, module];
907
+ if (!updatedProfile.modules.includes(module)) {
908
+ updatedProfile.modules = [...updatedProfile.modules, module];
909
+ }
861
910
  }
862
911
  for (const module of toRemove) {
863
- const managedRels = MODULE_MANAGED_FILES[module] ?? [];
912
+ const def = getModule(module);
913
+ const managedRels = def?.managedPaths ?? [];
864
914
  for (const rel of managedRels) {
865
915
  const result = removeManagedFileIfUnmodified(cwd, rel, stored);
866
916
  if (result === "deleted") {
867
- deletedFiles.push(rel);
917
+ deleted.push(rel);
868
918
  delete newChecksums[rel];
869
919
  } else {
870
- keptFiles.push(rel);
920
+ kept.push(rel);
871
921
  }
872
922
  }
873
923
  updatedProfile.modules = updatedProfile.modules.filter((m) => m !== module);
874
- if (module === "linkedin") {
875
- delete updatedProfile.linkedin;
924
+ if (def?.onRemove) {
925
+ updatedProfile = def.onRemove(updatedProfile);
926
+ }
927
+ }
928
+ writeState(cwd, { checksums: newChecksums });
929
+ const finalProfile = stripLegacyChecksums(updatedProfile);
930
+ writeProfile(cwd, finalProfile);
931
+ return { profile: finalProfile, added, skipped: skippedFiles, deleted, kept };
932
+ }
933
+ async function runUpdateModules(cwd, profile) {
934
+ const currentModules = profile.modules ?? [];
935
+ const selected = await p.multiselect({
936
+ message: `Which modules do you want active?${MULTISELECT_HINT}`,
937
+ options: MODULES.map((m) => ({
938
+ value: m.id,
939
+ label: m.label,
940
+ hint: chalk.hex("#64748B")(m.hint)
941
+ })),
942
+ initialValues: currentModules,
943
+ required: false
944
+ });
945
+ if (p.isCancel(selected)) {
946
+ p.cancel(chalk.hex("#94A3B8")("No changes made."));
947
+ return;
948
+ }
949
+ const selectedModules = Array.isArray(selected) ? selected : [];
950
+ const toAdd = selectedModules.filter((m) => !currentModules.includes(m));
951
+ const toRemove = currentModules.filter((m) => !selectedModules.includes(m));
952
+ if (toAdd.length === 0 && toRemove.length === 0) {
953
+ p.outro(chalk.hex("#94A3B8")("No changes \u2014 modules unchanged."));
954
+ return;
955
+ }
956
+ let liProfileUrl;
957
+ if (toAdd.includes("linkedin") && !profile.linkedin?.profile_url) {
958
+ const url = await p.text({
959
+ message: "What's your LinkedIn profile URL?",
960
+ placeholder: "https://linkedin.com/in/yourname (optional)"
961
+ });
962
+ if (p.isCancel(url)) {
963
+ p.cancel(chalk.hex("#94A3B8")("No changes made."));
964
+ return;
965
+ }
966
+ if (typeof url === "string" && url.trim()) {
967
+ liProfileUrl = url.trim();
876
968
  }
877
969
  }
878
- updatedProfile._checksums = newChecksums;
879
- writeProfile(cwd, updatedProfile);
970
+ const { added: addedFiles, skipped: skippedFiles, deleted: deletedFiles, kept: keptFiles } = applyModuleChanges(cwd, profile, toAdd, toRemove, { linkedin: { liProfileUrl } });
880
971
  const summaryLines = [];
881
972
  if (addedFiles.length > 0) summaryLines.push(chalk.hex("#94A3B8")(`Added: ${addedFiles.join(", ")}`));
882
973
  if (skippedFiles.length > 0) summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${skippedFiles.join(", ")}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-patina",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Personal professional knowledge graph — setup and management",
5
5
  "type": "module",
6
6
  "bin": {