shipfolio 1.0.9 → 1.1.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.
package/dist/cli.js CHANGED
@@ -81,7 +81,7 @@ function normalizeGitUrl(url) {
81
81
  }
82
82
  return url;
83
83
  }
84
- async function findGitRepos(rootPath, maxDepth = 3) {
84
+ async function findGitRepos(rootPath, maxDepth = 6) {
85
85
  const { glob: glob2 } = await import("glob");
86
86
  const gitDirs = await glob2("**/.git", {
87
87
  cwd: rootPath,
@@ -455,15 +455,20 @@ var init_logger = __esm({
455
455
  });
456
456
 
457
457
  // src/scanner/index.ts
458
+ import { dirname, resolve as resolvePath } from "path";
458
459
  import ora from "ora";
459
460
  async function scanProjects(directories) {
460
461
  const allPaths = [];
461
462
  const spinner = ora("Scanning for projects...").start();
462
463
  for (const dir of directories) {
463
464
  try {
464
- const repos = await findGitRepos(dir);
465
+ const repos = await findGitRepos(dir, DEFAULT_SCAN_DEPTH);
465
466
  allPaths.push(...repos);
466
- const nonGitDirs = await findNonGitProjects(dir, new Set(repos));
467
+ const nonGitDirs = await findNonGitProjects(
468
+ dir,
469
+ repos,
470
+ DEFAULT_SCAN_DEPTH
471
+ );
467
472
  allPaths.push(...nonGitDirs);
468
473
  } catch (err) {
469
474
  logger.warn(`Could not scan ${dir}: ${err}`);
@@ -492,31 +497,29 @@ async function scanProjects(directories) {
492
497
  spinner.succeed(`Scanned ${projects.length} projects`);
493
498
  return projects;
494
499
  }
495
- async function findNonGitProjects(rootPath, gitRepos) {
500
+ async function findNonGitProjects(rootPath, gitRepos, maxDepth) {
496
501
  const { glob: glob2 } = await import("glob");
497
- const indicators = [
498
- "*/package.json",
499
- "*/Cargo.toml",
500
- "*/go.mod",
501
- "*/pyproject.toml",
502
- "*/requirements.txt",
503
- "*/setup.py"
504
- ];
505
502
  const found = /* @__PURE__ */ new Set();
506
- for (const pattern of indicators) {
507
- const matches = await glob2(pattern, {
503
+ for (const indicator of NON_GIT_PROJECT_INDICATORS) {
504
+ const matches = await glob2(`**/${indicator}`, {
508
505
  cwd: rootPath,
509
- ignore: ["**/node_modules/**", "**/vendor/**"]
506
+ ignore: SCAN_IGNORE_PATTERNS,
507
+ maxDepth
510
508
  });
511
509
  for (const match of matches) {
512
- const dir = `${rootPath}/${match.split("/")[0]}`;
513
- if (!gitRepos.has(dir) && !found.has(dir)) {
514
- found.add(dir);
510
+ const candidateDir = dirname(resolvePath(rootPath, match));
511
+ if (!isInsideTrackedRepo(candidateDir, gitRepos) && !found.has(candidateDir)) {
512
+ found.add(candidateDir);
515
513
  }
516
514
  }
517
515
  }
518
516
  return [...found].sort();
519
517
  }
518
+ function isInsideTrackedRepo(candidateDir, gitRepos) {
519
+ return gitRepos.some(
520
+ (repoPath) => candidateDir === repoPath || candidateDir.startsWith(`${repoPath}/`)
521
+ );
522
+ }
520
523
  async function extractProjectMeta(projectPath) {
521
524
  const gitMeta = await getGitMeta(projectPath);
522
525
  let techStack = [];
@@ -576,6 +579,7 @@ async function extractProjectMeta(projectPath) {
576
579
  lastScannedCommit: gitMeta.lastCommitHash || ""
577
580
  };
578
581
  }
582
+ var DEFAULT_SCAN_DEPTH, NON_GIT_PROJECT_INDICATORS, SCAN_IGNORE_PATTERNS;
579
583
  var init_scanner = __esm({
580
584
  "src/scanner/index.ts"() {
581
585
  "use strict";
@@ -588,6 +592,25 @@ var init_scanner = __esm({
588
592
  init_generic();
589
593
  init_extractors();
590
594
  init_logger();
595
+ DEFAULT_SCAN_DEPTH = 6;
596
+ NON_GIT_PROJECT_INDICATORS = [
597
+ "package.json",
598
+ "Cargo.toml",
599
+ "go.mod",
600
+ "pyproject.toml",
601
+ "requirements.txt",
602
+ "setup.py"
603
+ ];
604
+ SCAN_IGNORE_PATTERNS = [
605
+ "**/node_modules/**",
606
+ "**/vendor/**",
607
+ "**/.git/**",
608
+ "**/.next/**",
609
+ "**/dist/**",
610
+ "**/build/**",
611
+ "**/out/**",
612
+ "**/coverage/**"
613
+ ];
591
614
  }
592
615
  });
593
616
 
@@ -626,6 +649,14 @@ var init_i18n = __esm({
626
649
  showSourceFor: (name) => `Show source code link for ${name}?`,
627
650
  roleFor: (name) => `Your role in ${name}:`,
628
651
  metricsFor: (name) => `Key metrics for ${name} (e.g. "1k users, $5k MRR"):`,
652
+ featureProjectFor: (name) => `Feature ${name} more prominently on the site?`,
653
+ caseStudyDetailsFor: (name) => `Add richer case-study details for ${name}?`,
654
+ audienceFor: (name) => `Primary audience for ${name}:`,
655
+ problemFor: (name) => `Problem or goal for ${name}:`,
656
+ solutionFor: (name) => `Your solution or key decision in ${name}:`,
657
+ impactFor: (name) => `Outcome / impact for ${name}:`,
658
+ evidenceFor: (name) => `Evidence for ${name} (comma-separated facts, proof points, links):`,
659
+ screenshotsFor: (name) => `Screenshot URLs for ${name} (comma-separated):`,
629
660
  optional: "optional",
630
661
  // Personal info
631
662
  personalInfo: "Personal Information",
@@ -672,6 +703,8 @@ var init_i18n = __esm({
672
703
  draftLoadPrompt: "Load draft? (skip re-entering previous answers)",
673
704
  draftSaved: "Draft saved. Your progress will be restored next time.",
674
705
  draftCleared: "Draft cleared.",
706
+ updateProfilePrompt: "Update profile / contact info too?",
707
+ reviewSectionsPrompt: "Review enabled sections too?",
675
708
  // Roles
676
709
  roleSolo: "Solo",
677
710
  roleLead: "Lead",
@@ -727,6 +760,14 @@ var init_i18n = __esm({
727
760
  showSourceFor: (name) => `\u662F\u5426\u5C55\u793A ${name} \u7684\u6E90\u7801\u94FE\u63A5?`,
728
761
  roleFor: (name) => `\u4F60\u5728 ${name} \u4E2D\u7684\u89D2\u8272:`,
729
762
  metricsFor: (name) => `${name} \u7684\u5173\u952E\u6307\u6807 (\u5982 "1k \u7528\u6237, $5k MRR"):`,
763
+ featureProjectFor: (name) => `\u662F\u5426\u5C06 ${name} \u8BBE\u4E3A\u91CD\u70B9\u9879\u76EE?`,
764
+ caseStudyDetailsFor: (name) => `\u662F\u5426\u4E3A ${name} \u8865\u5145\u66F4\u5B8C\u6574\u7684\u6848\u4F8B\u4FE1\u606F?`,
765
+ audienceFor: (name) => `${name} \u7684\u4E3B\u8981\u7528\u6237/\u53D7\u4F17:`,
766
+ problemFor: (name) => `${name} \u89E3\u51B3\u7684\u95EE\u9898\u6216\u76EE\u6807:`,
767
+ solutionFor: (name) => `\u4F60\u5728 ${name} \u4E2D\u7684\u65B9\u6848\u6216\u5173\u952E\u51B3\u7B56:`,
768
+ impactFor: (name) => `${name} \u7684\u7ED3\u679C / \u5F71\u54CD:`,
769
+ evidenceFor: (name) => `${name} \u7684\u8BC1\u660E\u6750\u6599 (\u9017\u53F7\u5206\u9694: \u4E8B\u5B9E\u3001\u6307\u6807\u3001\u94FE\u63A5):`,
770
+ screenshotsFor: (name) => `${name} \u7684\u622A\u56FE\u94FE\u63A5 (\u9017\u53F7\u5206\u9694):`,
730
771
  optional: "\u53EF\u9009",
731
772
  personalInfo: "\u4E2A\u4EBA\u4FE1\u606F",
732
773
  fullName: "\u4F60\u7684\u5168\u540D:",
@@ -767,6 +808,8 @@ var init_i18n = __esm({
767
808
  draftLoadPrompt: "\u662F\u5426\u52A0\u8F7D\u8349\u7A3F? (\u8DF3\u8FC7\u91CD\u590D\u586B\u5199)",
768
809
  draftSaved: "\u8349\u7A3F\u5DF2\u4FDD\u5B58, \u4E0B\u6B21\u8FD0\u884C\u65F6\u53EF\u6062\u590D.",
769
810
  draftCleared: "\u8349\u7A3F\u5DF2\u6E05\u9664.",
811
+ updateProfilePrompt: "\u662F\u5426\u540C\u65F6\u66F4\u65B0\u4E2A\u4EBA\u8D44\u6599 / \u8054\u7CFB\u65B9\u5F0F?",
812
+ reviewSectionsPrompt: "\u662F\u5426\u540C\u65F6\u68C0\u67E5\u542F\u7528\u7684\u9875\u9762\u533A\u5757?",
770
813
  roleSolo: "\u72EC\u7ACB\u5B8C\u6210",
771
814
  roleLead: "\u4E3B\u5BFC\u5F00\u53D1",
772
815
  roleContributor: "\u53C2\u4E0E\u8D21\u732E",
@@ -861,6 +904,102 @@ var init_questions = __esm({
861
904
  }
862
905
  });
863
906
 
907
+ // src/spec/project-utils.ts
908
+ function slugify(value) {
909
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
910
+ }
911
+ function uniqueStrings(values) {
912
+ return [...new Set(values.filter(Boolean))];
913
+ }
914
+ function defaultCaseStudy(featured = false) {
915
+ return {
916
+ featured,
917
+ audience: null,
918
+ problem: null,
919
+ solution: null,
920
+ impact: null,
921
+ evidence: [],
922
+ screenshots: []
923
+ };
924
+ }
925
+ function buildTrackingSignature(projects) {
926
+ return projects.map((project) => `${project.localPath}:${project.lastScannedCommit || "none"}`).sort().join("|");
927
+ }
928
+ function buildMergedProjectMeta(projects, name) {
929
+ const techStack = uniqueStrings(projects.flatMap((project) => project.techStack));
930
+ const languages = {};
931
+ for (const project of projects) {
932
+ for (const [lang, count] of Object.entries(project.languages)) {
933
+ languages[lang] = (languages[lang] || 0) + count;
934
+ }
935
+ }
936
+ const firstDates = projects.map((project) => project.firstCommitDate).filter(Boolean).sort();
937
+ const lastDates = projects.map((project) => project.lastCommitDate).filter(Boolean).sort();
938
+ const totalCommits = projects.reduce(
939
+ (sum, project) => sum + project.totalCommits,
940
+ 0
941
+ );
942
+ const remoteUrl = projects.find((project) => project.remoteUrl)?.remoteUrl || null;
943
+ const demoUrl = projects.find((project) => project.demoUrl)?.demoUrl || null;
944
+ const readmeParts = projects.filter((project) => project.readmeContent).map((project) => `--- ${project.name} ---
945
+ ${project.readmeContent}`);
946
+ const readmeContent = readmeParts.length > 0 ? readmeParts.join("\n\n") : null;
947
+ const descriptions = projects.map((project) => project.description).filter(Boolean);
948
+ return {
949
+ id: slugify(name),
950
+ name,
951
+ localPath: projects[0]?.localPath || "",
952
+ description: descriptions.join(" | "),
953
+ techStack,
954
+ languages,
955
+ firstCommitDate: firstDates[0] || "",
956
+ lastCommitDate: lastDates[lastDates.length - 1] || "",
957
+ totalCommits,
958
+ remoteUrl,
959
+ demoUrl,
960
+ readmeContent,
961
+ lastScannedCommit: buildTrackingSignature(projects)
962
+ };
963
+ }
964
+ function getTrackedProjectPaths(project) {
965
+ if (project.trackedProjectPaths && project.trackedProjectPaths.length > 0) {
966
+ return uniqueStrings(project.trackedProjectPaths);
967
+ }
968
+ if (project.children && project.children.length > 0) {
969
+ return uniqueStrings(project.children.map((child) => child.localPath));
970
+ }
971
+ return project.localPath ? [project.localPath] : [];
972
+ }
973
+ function createProjectEntry(meta, overrides = {}) {
974
+ const baseMetrics = overrides.metrics || {};
975
+ const baseCaseStudy = overrides.caseStudy ? {
976
+ ...defaultCaseStudy(overrides.caseStudy.featured),
977
+ ...overrides.caseStudy,
978
+ evidence: overrides.caseStudy.evidence || [],
979
+ screenshots: overrides.caseStudy.screenshots || []
980
+ } : defaultCaseStudy(false);
981
+ return {
982
+ ...meta,
983
+ included: overrides.included ?? true,
984
+ overrideDescription: overrides.overrideDescription ?? null,
985
+ showSourceLink: overrides.showSourceLink ?? !!meta.remoteUrl,
986
+ role: overrides.role ?? "solo",
987
+ trackedProjectPaths: overrides.trackedProjectPaths && overrides.trackedProjectPaths.length > 0 ? uniqueStrings(overrides.trackedProjectPaths) : [meta.localPath],
988
+ metrics: {
989
+ ...baseMetrics,
990
+ custom: baseMetrics.custom || void 0
991
+ },
992
+ caseStudy: baseCaseStudy,
993
+ children: overrides.children
994
+ };
995
+ }
996
+ var init_project_utils = __esm({
997
+ "src/spec/project-utils.ts"() {
998
+ "use strict";
999
+ init_esm_shims();
1000
+ }
1001
+ });
1002
+
864
1003
  // src/interviewer/index.ts
865
1004
  import * as p from "@clack/prompts";
866
1005
  function handleCancel(value) {
@@ -869,6 +1008,38 @@ function handleCancel(value) {
869
1008
  process.exit(0);
870
1009
  }
871
1010
  }
1011
+ function inferOwnerName() {
1012
+ return process.env.SHIPFOLIO_NAME || process.env.GIT_AUTHOR_NAME || process.env.USER || process.env.USERNAME || "Portfolio Owner";
1013
+ }
1014
+ function sanitizeProjectName(value) {
1015
+ const sanitized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1016
+ return sanitized || "my-shipfolio";
1017
+ }
1018
+ function parseCommaSeparatedList(value) {
1019
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
1020
+ }
1021
+ function getAdditionalSectionOptions(projects, owner) {
1022
+ return SECTION_OPTIONS().filter((option) => {
1023
+ switch (option.value) {
1024
+ case "blog":
1025
+ return !!owner.social.blog;
1026
+ case "timeline":
1027
+ return projects.some(
1028
+ (project) => !!project.firstCommitDate || !!project.lastCommitDate
1029
+ );
1030
+ case "metrics":
1031
+ return projects.some(
1032
+ (project) => !!project.metrics.users || !!project.metrics.revenue || !!project.metrics.downloads || !!project.metrics.stars || !!project.metrics.custom || !!project.caseStudy.impact || project.caseStudy.evidence.length > 0
1033
+ );
1034
+ case "contact":
1035
+ return Boolean(
1036
+ owner.social.email || owner.social.github || owner.social.linkedin || owner.social.twitter || owner.social.blog
1037
+ );
1038
+ default:
1039
+ return true;
1040
+ }
1041
+ });
1042
+ }
872
1043
  async function runMergeStep(projects) {
873
1044
  const shouldMerge = await p.confirm({
874
1045
  message: t().mergePrompt,
@@ -928,51 +1099,15 @@ function mergeProjects(projects, name) {
928
1099
  allChildren.push(rest);
929
1100
  }
930
1101
  }
931
- const techStack = [
932
- ...new Set(allChildren.flatMap((p6) => p6.techStack))
933
- ];
934
- const languages = {};
935
- for (const child of allChildren) {
936
- for (const [lang, count] of Object.entries(child.languages)) {
937
- languages[lang] = (languages[lang] || 0) + count;
938
- }
939
- }
940
- const firstDates = allChildren.map((p6) => p6.firstCommitDate).filter(Boolean).sort();
941
- const lastDates = allChildren.map((p6) => p6.lastCommitDate).filter(Boolean).sort();
942
- const totalCommits = allChildren.reduce(
943
- (sum, p6) => sum + p6.totalCommits,
944
- 0
945
- );
946
- const remoteUrl = allChildren.find((p6) => p6.remoteUrl)?.remoteUrl || null;
947
- const demoUrl = allChildren.find((p6) => p6.demoUrl)?.demoUrl || null;
948
- const readmeParts = allChildren.filter((p6) => p6.readmeContent).map(
949
- (p6) => `--- ${p6.name} ---
950
- ${p6.readmeContent}`
951
- );
952
- const readmeContent = readmeParts.length > 0 ? readmeParts.join("\n\n") : null;
953
- const descriptions = allChildren.map((p6) => p6.description).filter(Boolean);
954
- const description = descriptions.join(" | ");
955
- const localPath = allChildren[0].localPath;
956
- const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1102
+ const mergedMeta = buildMergedProjectMeta(allChildren, name);
957
1103
  return {
958
- id,
959
- name,
960
- localPath,
961
- description,
962
- techStack,
963
- languages,
964
- firstCommitDate: firstDates[0] || "",
965
- lastCommitDate: lastDates[lastDates.length - 1] || "",
966
- totalCommits,
967
- remoteUrl,
968
- demoUrl,
969
- readmeContent,
970
- lastScannedCommit: allChildren[0].lastScannedCommit || "",
1104
+ ...mergedMeta,
971
1105
  children: allChildren
972
1106
  };
973
1107
  }
974
- async function runInterview(scannedProjects, availableEngines) {
1108
+ async function runInterview(scannedProjects, availableEngines, options = {}) {
975
1109
  p.intro("shipfolio");
1110
+ const auto = options.auto === true;
976
1111
  logger.header("Project Selection");
977
1112
  const projectOptions = scannedProjects.map((proj) => {
978
1113
  const techInfo = proj.techStack.slice(0, 3).join(", ");
@@ -983,23 +1118,45 @@ async function runInterview(scannedProjects, availableEngines) {
983
1118
  hint: techInfo ? `${techInfo} | ${commitInfo}` : commitInfo
984
1119
  };
985
1120
  });
986
- const selectedIds = await p.multiselect({
987
- message: t().selectProjects,
988
- options: projectOptions,
989
- required: true
990
- });
991
- handleCancel(selectedIds);
1121
+ let selectedIds;
1122
+ if (auto) {
1123
+ selectedIds = scannedProjects.map((proj) => proj.id);
1124
+ logger.info(`Auto mode: selected all ${selectedIds.length} scanned projects.`);
1125
+ } else {
1126
+ selectedIds = await p.multiselect({
1127
+ message: t().selectProjects,
1128
+ options: projectOptions,
1129
+ required: true
1130
+ });
1131
+ handleCancel(selectedIds);
1132
+ }
992
1133
  let selectedMetas = selectedIds.map(
993
1134
  (id) => scannedProjects.find((p6) => p6.id === id)
994
1135
  );
995
- if (selectedMetas.length >= 2) {
1136
+ if (!auto && selectedMetas.length >= 2) {
996
1137
  selectedMetas = await runMergeStep(selectedMetas);
997
1138
  }
1139
+ const autoFeaturedIds = new Set(
1140
+ [...selectedMetas].sort((a, b) => b.totalCommits - a.totalCommits).slice(0, 3).map((project) => project.id)
1141
+ );
998
1142
  const projectEntries = [];
999
1143
  for (const meta of selectedMetas) {
1000
1144
  const displayName = meta.children ? `${meta.name} (${meta.children.length} sub-projects)` : meta.name;
1001
1145
  logger.plain(`
1002
1146
  ${t().configuring(displayName)}`);
1147
+ if (auto) {
1148
+ projectEntries.push(
1149
+ createProjectEntry(meta, {
1150
+ showSourceLink: !!meta.remoteUrl,
1151
+ trackedProjectPaths: meta.children?.map((child) => child.localPath) || [
1152
+ meta.localPath
1153
+ ],
1154
+ children: meta.children,
1155
+ caseStudy: defaultCaseStudy(autoFeaturedIds.has(meta.id))
1156
+ })
1157
+ );
1158
+ continue;
1159
+ }
1003
1160
  const overrideDesc = await p.text({
1004
1161
  message: t().descriptionFor(displayName),
1005
1162
  placeholder: meta.description?.slice(0, 80) || t().descriptionPlaceholder,
@@ -1032,119 +1189,229 @@ async function runInterview(scannedProjects, availableEngines) {
1032
1189
  if (metricsInput) {
1033
1190
  metrics.custom = { summary: metricsInput };
1034
1191
  }
1035
- projectEntries.push({
1036
- ...meta,
1037
- included: true,
1038
- overrideDescription: overrideDesc || null,
1039
- showSourceLink: showSource,
1040
- demoUrl: demoUrl || meta.demoUrl,
1041
- role,
1042
- metrics
1192
+ const featured = await p.confirm({
1193
+ message: t().featureProjectFor(displayName),
1194
+ initialValue: autoFeaturedIds.has(meta.id)
1195
+ });
1196
+ handleCancel(featured);
1197
+ const addCaseStudyDetails = await p.confirm({
1198
+ message: t().caseStudyDetailsFor(displayName),
1199
+ initialValue: false
1043
1200
  });
1201
+ handleCancel(addCaseStudyDetails);
1202
+ let audience = null;
1203
+ let problem = null;
1204
+ let solution = null;
1205
+ let impact = null;
1206
+ let evidence = [];
1207
+ let screenshots = [];
1208
+ if (addCaseStudyDetails) {
1209
+ const audienceInput = await p.text({
1210
+ message: t().audienceFor(displayName),
1211
+ placeholder: t().optional,
1212
+ defaultValue: ""
1213
+ });
1214
+ handleCancel(audienceInput);
1215
+ audience = audienceInput || null;
1216
+ const problemInput = await p.text({
1217
+ message: t().problemFor(displayName),
1218
+ placeholder: t().optional,
1219
+ defaultValue: ""
1220
+ });
1221
+ handleCancel(problemInput);
1222
+ problem = problemInput || null;
1223
+ const solutionInput = await p.text({
1224
+ message: t().solutionFor(displayName),
1225
+ placeholder: t().optional,
1226
+ defaultValue: ""
1227
+ });
1228
+ handleCancel(solutionInput);
1229
+ solution = solutionInput || null;
1230
+ const impactInput = await p.text({
1231
+ message: t().impactFor(displayName),
1232
+ placeholder: t().optional,
1233
+ defaultValue: metricsInput || ""
1234
+ });
1235
+ handleCancel(impactInput);
1236
+ impact = impactInput || null;
1237
+ const evidenceInput = await p.text({
1238
+ message: t().evidenceFor(displayName),
1239
+ placeholder: t().optional,
1240
+ defaultValue: ""
1241
+ });
1242
+ handleCancel(evidenceInput);
1243
+ evidence = parseCommaSeparatedList(evidenceInput);
1244
+ const screenshotsInput = await p.text({
1245
+ message: t().screenshotsFor(displayName),
1246
+ placeholder: t().optional,
1247
+ defaultValue: ""
1248
+ });
1249
+ handleCancel(screenshotsInput);
1250
+ screenshots = parseCommaSeparatedList(screenshotsInput);
1251
+ }
1252
+ projectEntries.push(
1253
+ createProjectEntry(
1254
+ {
1255
+ ...meta,
1256
+ demoUrl: demoUrl || meta.demoUrl
1257
+ },
1258
+ {
1259
+ overrideDescription: overrideDesc || null,
1260
+ showSourceLink: showSource,
1261
+ role,
1262
+ metrics,
1263
+ trackedProjectPaths: meta.children?.map((child) => child.localPath) || [
1264
+ meta.localPath
1265
+ ],
1266
+ children: meta.children,
1267
+ caseStudy: {
1268
+ featured,
1269
+ audience,
1270
+ problem,
1271
+ solution,
1272
+ impact,
1273
+ evidence,
1274
+ screenshots
1275
+ }
1276
+ }
1277
+ )
1278
+ );
1044
1279
  }
1045
1280
  logger.header(t().personalInfo);
1046
- const name = await p.text({
1047
- message: t().fullName,
1048
- validate: (v) => v.length === 0 ? t().nameRequired : void 0
1049
- });
1050
- handleCancel(name);
1051
- const tagline = await p.text({
1052
- message: t().tagline,
1053
- placeholder: t().taglinePlaceholder
1054
- });
1055
- handleCancel(tagline);
1056
- const bioChoice = await p.select({
1057
- message: t().bio,
1058
- options: [
1059
- { value: "auto", label: t().bioAuto },
1060
- { value: "manual", label: t().bioManual }
1061
- ]
1062
- });
1063
- handleCancel(bioChoice);
1064
- let bio = "auto";
1065
- if (bioChoice === "manual") {
1066
- bio = await p.text({
1067
- message: t().bioPrompt
1281
+ let owner;
1282
+ if (auto) {
1283
+ owner = {
1284
+ name: inferOwnerName(),
1285
+ tagline: t().taglinePlaceholder,
1286
+ bio: "auto",
1287
+ photoUrl: null,
1288
+ social: {}
1289
+ };
1290
+ logger.info(`Auto mode: using "${owner.name}" as the portfolio owner name.`);
1291
+ } else {
1292
+ const name = await p.text({
1293
+ message: t().fullName,
1294
+ validate: (v) => v.length === 0 ? t().nameRequired : void 0
1068
1295
  });
1069
- handleCancel(bio);
1070
- }
1071
- const photoUrl = await p.text({
1072
- message: t().photoUrl,
1073
- placeholder: "https://...",
1074
- defaultValue: ""
1075
- });
1076
- handleCancel(photoUrl);
1077
- const github = await p.text({
1078
- message: t().githubUser,
1079
- defaultValue: ""
1080
- });
1081
- handleCancel(github);
1082
- const twitter = await p.text({
1083
- message: t().twitterHandle,
1084
- defaultValue: ""
1085
- });
1086
- handleCancel(twitter);
1087
- const linkedin = await p.text({
1088
- message: t().linkedinUrl,
1089
- defaultValue: ""
1090
- });
1091
- handleCancel(linkedin);
1092
- const blogUrl = await p.text({
1093
- message: t().blogUrl,
1094
- defaultValue: ""
1095
- });
1096
- handleCancel(blogUrl);
1097
- const email = await p.text({
1098
- message: t().contactEmail,
1099
- defaultValue: ""
1100
- });
1101
- handleCancel(email);
1102
- const owner = {
1103
- name,
1104
- tagline,
1105
- bio,
1106
- photoUrl: photoUrl || null,
1107
- social: {
1108
- github: github || void 0,
1109
- twitter: twitter || void 0,
1110
- linkedin: linkedin || void 0,
1111
- blog: blogUrl || void 0,
1112
- email: email || void 0
1296
+ handleCancel(name);
1297
+ const tagline = await p.text({
1298
+ message: t().tagline,
1299
+ placeholder: t().taglinePlaceholder
1300
+ });
1301
+ handleCancel(tagline);
1302
+ const bioChoice = await p.select({
1303
+ message: t().bio,
1304
+ options: [
1305
+ { value: "auto", label: t().bioAuto },
1306
+ { value: "manual", label: t().bioManual }
1307
+ ]
1308
+ });
1309
+ handleCancel(bioChoice);
1310
+ let bio = "auto";
1311
+ if (bioChoice === "manual") {
1312
+ bio = await p.text({
1313
+ message: t().bioPrompt
1314
+ });
1315
+ handleCancel(bio);
1113
1316
  }
1114
- };
1317
+ const photoUrl = await p.text({
1318
+ message: t().photoUrl,
1319
+ placeholder: "https://...",
1320
+ defaultValue: ""
1321
+ });
1322
+ handleCancel(photoUrl);
1323
+ const github = await p.text({
1324
+ message: t().githubUser,
1325
+ defaultValue: ""
1326
+ });
1327
+ handleCancel(github);
1328
+ const twitter = await p.text({
1329
+ message: t().twitterHandle,
1330
+ defaultValue: ""
1331
+ });
1332
+ handleCancel(twitter);
1333
+ const linkedin = await p.text({
1334
+ message: t().linkedinUrl,
1335
+ defaultValue: ""
1336
+ });
1337
+ handleCancel(linkedin);
1338
+ const blogUrl = await p.text({
1339
+ message: t().blogUrl,
1340
+ defaultValue: ""
1341
+ });
1342
+ handleCancel(blogUrl);
1343
+ const email = await p.text({
1344
+ message: t().contactEmail,
1345
+ defaultValue: ""
1346
+ });
1347
+ handleCancel(email);
1348
+ owner = {
1349
+ name,
1350
+ tagline,
1351
+ bio,
1352
+ photoUrl: photoUrl || null,
1353
+ social: {
1354
+ github: github || void 0,
1355
+ twitter: twitter || void 0,
1356
+ linkedin: linkedin || void 0,
1357
+ blog: blogUrl || void 0,
1358
+ email: email || void 0
1359
+ }
1360
+ };
1361
+ }
1115
1362
  logger.header(t().designPrefs);
1116
- const theme = await p.select({
1117
- message: t().theme,
1118
- options: THEME_OPTIONS()
1119
- });
1120
- handleCancel(theme);
1121
- const accentColor = await p.select({
1122
- message: t().accentColor,
1123
- options: [
1124
- ...DEFAULT_ACCENT_COLORS(),
1125
- { value: "custom", label: t().customHex }
1126
- ]
1127
- });
1128
- handleCancel(accentColor);
1129
- let finalAccent = accentColor;
1130
- if (accentColor === "custom") {
1131
- finalAccent = await p.text({
1132
- message: t().customHexPrompt,
1133
- placeholder: "#7c3aed",
1134
- validate: (v) => /^#[0-9a-fA-F]{6}$/.test(v) ? void 0 : t().invalidHex
1363
+ let theme;
1364
+ if (options.style) {
1365
+ theme = options.style;
1366
+ logger.info(`Theme preset: ${theme}`);
1367
+ } else if (auto) {
1368
+ theme = "dark-minimal";
1369
+ } else {
1370
+ theme = await p.select({
1371
+ message: t().theme,
1372
+ options: THEME_OPTIONS()
1135
1373
  });
1136
- handleCancel(finalAccent);
1374
+ handleCancel(theme);
1375
+ }
1376
+ let finalAccent;
1377
+ if (options.accent) {
1378
+ finalAccent = options.accent;
1379
+ logger.info(`Accent preset: ${finalAccent}`);
1380
+ } else if (auto) {
1381
+ finalAccent = "#7c3aed";
1382
+ } else {
1383
+ const accentColor = await p.select({
1384
+ message: t().accentColor,
1385
+ options: [
1386
+ ...DEFAULT_ACCENT_COLORS(),
1387
+ { value: "custom", label: t().customHex }
1388
+ ]
1389
+ });
1390
+ handleCancel(accentColor);
1391
+ finalAccent = accentColor;
1392
+ if (accentColor === "custom") {
1393
+ finalAccent = await p.text({
1394
+ message: t().customHexPrompt,
1395
+ placeholder: "#7c3aed",
1396
+ validate: (v) => /^#[0-9a-fA-F]{6}$/.test(v) ? void 0 : t().invalidHex
1397
+ });
1398
+ handleCancel(finalAccent);
1399
+ }
1137
1400
  }
1138
- const font = await p.select({
1401
+ const font = auto ? "Inter" : await p.select({
1139
1402
  message: t().font,
1140
1403
  options: FONT_OPTIONS()
1141
1404
  });
1142
- handleCancel(font);
1143
- const animationLevel = await p.select({
1405
+ if (!auto) {
1406
+ handleCancel(font);
1407
+ }
1408
+ const animationLevel = auto ? "subtle" : await p.select({
1144
1409
  message: t().animationLevel,
1145
1410
  options: ANIMATION_OPTIONS()
1146
1411
  });
1147
- handleCancel(animationLevel);
1412
+ if (!auto) {
1413
+ handleCancel(animationLevel);
1414
+ }
1148
1415
  const style = {
1149
1416
  theme,
1150
1417
  accentColor: finalAccent,
@@ -1152,20 +1419,34 @@ async function runInterview(scannedProjects, availableEngines) {
1152
1419
  animationLevel
1153
1420
  };
1154
1421
  logger.header(t().sections);
1155
- const additionalSections = await p.multiselect({
1156
- message: t().additionalSections,
1157
- options: SECTION_OPTIONS(),
1158
- required: false
1159
- });
1160
- handleCancel(additionalSections);
1161
- const sections = [
1162
- "hero",
1163
- "projects",
1164
- ...additionalSections
1165
- ];
1422
+ let sections;
1423
+ if (auto) {
1424
+ sections = ["hero", "projects", "about", "contact"];
1425
+ } else {
1426
+ const sectionOptions = getAdditionalSectionOptions(projectEntries, owner);
1427
+ const additionalSections = sectionOptions.length > 0 ? await p.multiselect({
1428
+ message: t().additionalSections,
1429
+ options: sectionOptions,
1430
+ required: false
1431
+ }) : [];
1432
+ if (sectionOptions.length > 0) {
1433
+ handleCancel(additionalSections);
1434
+ }
1435
+ sections = [
1436
+ "hero",
1437
+ "projects",
1438
+ ...additionalSections
1439
+ ];
1440
+ }
1166
1441
  logger.header(t().aiEngine);
1167
1442
  let engine;
1168
- if (availableEngines.length === 1) {
1443
+ if (options.engine && availableEngines.includes(options.engine)) {
1444
+ engine = options.engine;
1445
+ logger.info(t().engineUsing(engine));
1446
+ } else if (availableEngines.length === 1) {
1447
+ engine = availableEngines[0];
1448
+ logger.info(t().engineUsing(engine));
1449
+ } else if (auto) {
1169
1450
  engine = availableEngines[0];
1170
1451
  logger.info(t().engineUsing(engine));
1171
1452
  } else {
@@ -1179,26 +1460,38 @@ async function runInterview(scannedProjects, availableEngines) {
1179
1460
  handleCancel(engine);
1180
1461
  }
1181
1462
  logger.header(t().deployment);
1182
- const deployPlatform = await p.select({
1183
- message: t().deployTo,
1184
- options: DEPLOY_OPTIONS()
1185
- });
1186
- handleCancel(deployPlatform);
1463
+ let deployPlatform;
1464
+ if (options.deploy) {
1465
+ deployPlatform = options.deploy;
1466
+ logger.info(`Deploy preset: ${deployPlatform}`);
1467
+ } else if (auto) {
1468
+ deployPlatform = "local";
1469
+ } else {
1470
+ deployPlatform = await p.select({
1471
+ message: t().deployTo,
1472
+ options: DEPLOY_OPTIONS()
1473
+ });
1474
+ handleCancel(deployPlatform);
1475
+ }
1187
1476
  let projectName = "";
1188
1477
  let customDomain = "";
1189
1478
  if (deployPlatform !== "local") {
1190
- projectName = await p.text({
1191
- message: t().projectNamePrompt,
1192
- placeholder: "my-shipfolio",
1193
- validate: (v) => /^[a-z0-9-]+$/.test(v) ? void 0 : t().projectNameInvalid
1194
- });
1195
- handleCancel(projectName);
1196
- customDomain = await p.text({
1197
- message: t().customDomainPrompt,
1198
- placeholder: "portfolio.example.com",
1199
- defaultValue: ""
1200
- });
1201
- handleCancel(customDomain);
1479
+ if (auto) {
1480
+ projectName = sanitizeProjectName(`${owner.name}-shipfolio`);
1481
+ } else {
1482
+ projectName = await p.text({
1483
+ message: t().projectNamePrompt,
1484
+ placeholder: "my-shipfolio",
1485
+ validate: (v) => /^[a-z0-9-]+$/.test(v) ? void 0 : t().projectNameInvalid
1486
+ });
1487
+ handleCancel(projectName);
1488
+ customDomain = await p.text({
1489
+ message: t().customDomainPrompt,
1490
+ placeholder: "portfolio.example.com",
1491
+ defaultValue: ""
1492
+ });
1493
+ handleCancel(customDomain);
1494
+ }
1202
1495
  }
1203
1496
  p.outro(t().configComplete);
1204
1497
  return {
@@ -1221,13 +1514,121 @@ var init_interviewer = __esm({
1221
1514
  init_questions();
1222
1515
  init_logger();
1223
1516
  init_i18n();
1517
+ init_project_utils();
1518
+ }
1519
+ });
1520
+
1521
+ // src/spec/schema.ts
1522
+ import { z } from "zod";
1523
+ function parseShipfolioSpec(input) {
1524
+ return shipfolioSpecSchema.parse(input);
1525
+ }
1526
+ function parseShipfolioConfig(input) {
1527
+ return shipfolioConfigSchema.parse(input);
1528
+ }
1529
+ var sectionIdSchema, engineTypeSchema, deployPlatformSchema, styleConfigSchema, projectMetaSchema, projectCaseStudySchema, ownerInfoSchema, projectEntrySchema, deployConfigSchema, shipfolioSpecSchema, shipfolioConfigSchema;
1530
+ var init_schema = __esm({
1531
+ "src/spec/schema.ts"() {
1532
+ "use strict";
1533
+ init_esm_shims();
1534
+ sectionIdSchema = z.enum([
1535
+ "hero",
1536
+ "projects",
1537
+ "skills",
1538
+ "about",
1539
+ "timeline",
1540
+ "blog",
1541
+ "metrics",
1542
+ "contact"
1543
+ ]);
1544
+ engineTypeSchema = z.enum(["claude", "codex", "v0"]);
1545
+ deployPlatformSchema = z.enum(["cloudflare", "vercel", "local"]);
1546
+ styleConfigSchema = z.object({
1547
+ theme: z.enum(["dark-minimal", "light-clean", "monochrome", "custom"]),
1548
+ accentColor: z.string(),
1549
+ font: z.string(),
1550
+ animationLevel: z.enum(["none", "subtle", "moderate"])
1551
+ });
1552
+ projectMetaSchema = z.object({
1553
+ id: z.string(),
1554
+ name: z.string(),
1555
+ localPath: z.string(),
1556
+ description: z.string(),
1557
+ techStack: z.array(z.string()),
1558
+ languages: z.record(z.number()),
1559
+ firstCommitDate: z.string(),
1560
+ lastCommitDate: z.string(),
1561
+ totalCommits: z.number().int().nonnegative(),
1562
+ remoteUrl: z.string().nullable(),
1563
+ demoUrl: z.string().nullable(),
1564
+ readmeContent: z.string().nullable(),
1565
+ lastScannedCommit: z.string()
1566
+ });
1567
+ projectCaseStudySchema = z.object({
1568
+ featured: z.boolean(),
1569
+ audience: z.string().nullable(),
1570
+ problem: z.string().nullable(),
1571
+ solution: z.string().nullable(),
1572
+ impact: z.string().nullable(),
1573
+ evidence: z.array(z.string()),
1574
+ screenshots: z.array(z.string())
1575
+ });
1576
+ ownerInfoSchema = z.object({
1577
+ name: z.string(),
1578
+ tagline: z.string(),
1579
+ bio: z.union([z.literal("auto"), z.string()]),
1580
+ photoUrl: z.string().nullable(),
1581
+ social: z.object({
1582
+ github: z.string().optional(),
1583
+ twitter: z.string().optional(),
1584
+ linkedin: z.string().optional(),
1585
+ blog: z.string().optional(),
1586
+ email: z.string().optional()
1587
+ })
1588
+ });
1589
+ projectEntrySchema = projectMetaSchema.extend({
1590
+ included: z.boolean(),
1591
+ overrideDescription: z.string().nullable(),
1592
+ showSourceLink: z.boolean(),
1593
+ role: z.enum(["solo", "lead", "contributor"]),
1594
+ trackedProjectPaths: z.array(z.string()),
1595
+ metrics: z.object({
1596
+ users: z.string().optional(),
1597
+ revenue: z.string().optional(),
1598
+ downloads: z.string().optional(),
1599
+ stars: z.number().int().optional(),
1600
+ custom: z.record(z.string()).optional()
1601
+ }),
1602
+ caseStudy: projectCaseStudySchema,
1603
+ children: z.array(projectMetaSchema).optional()
1604
+ });
1605
+ deployConfigSchema = z.object({
1606
+ platform: deployPlatformSchema,
1607
+ projectName: z.string(),
1608
+ customDomain: z.string().optional(),
1609
+ url: z.string().optional()
1610
+ });
1611
+ shipfolioSpecSchema = z.object({
1612
+ version: z.string(),
1613
+ generatedAt: z.string(),
1614
+ engine: engineTypeSchema,
1615
+ framework: z.literal("next"),
1616
+ style: styleConfigSchema,
1617
+ owner: ownerInfoSchema,
1618
+ projects: z.array(projectEntrySchema),
1619
+ sections: z.array(sectionIdSchema),
1620
+ deploy: deployConfigSchema
1621
+ });
1622
+ shipfolioConfigSchema = shipfolioSpecSchema.extend({
1623
+ sitePath: z.string()
1624
+ });
1224
1625
  }
1225
1626
  });
1226
1627
 
1227
1628
  // src/spec/builder.ts
1228
1629
  function buildSpec(interview) {
1229
- return {
1230
- version: "1.0.0",
1630
+ return parseShipfolioSpec({
1631
+ version: "1.1.0",
1231
1632
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1232
1633
  engine: interview.engine,
1233
1634
  framework: "next",
@@ -1240,208 +1641,114 @@ function buildSpec(interview) {
1240
1641
  projectName: interview.deploy.projectName,
1241
1642
  customDomain: interview.deploy.customDomain
1242
1643
  }
1243
- };
1644
+ });
1244
1645
  }
1245
1646
  var init_builder = __esm({
1246
1647
  "src/spec/builder.ts"() {
1247
1648
  "use strict";
1248
1649
  init_esm_shims();
1650
+ init_schema();
1249
1651
  }
1250
1652
  });
1251
1653
 
1252
1654
  // src/orchestrator/prompt-builder.ts
1655
+ import { fileURLToPath as fileURLToPath2 } from "url";
1656
+ async function loadPromptTemplate(filename) {
1657
+ const cached = promptTemplateCache.get(filename);
1658
+ if (cached) {
1659
+ return cached;
1660
+ }
1661
+ const candidates = [
1662
+ fileURLToPath2(new URL(`../prompts/${filename}`, import.meta.url)),
1663
+ fileURLToPath2(new URL(`../../prompts/${filename}`, import.meta.url)),
1664
+ fileURLToPath2(new URL(`../../../prompts/${filename}`, import.meta.url))
1665
+ ];
1666
+ for (const path2 of candidates) {
1667
+ if (await fileExists(path2)) {
1668
+ const template = await readText(path2);
1669
+ promptTemplateCache.set(filename, template);
1670
+ return template;
1671
+ }
1672
+ }
1673
+ throw new Error(`Prompt template not found: ${filename}`);
1674
+ }
1675
+ function fillTemplate(template, replacements) {
1676
+ return Object.entries(replacements).reduce((text4, [key, value]) => {
1677
+ return text4.split(`{{${key}}}`).join(value);
1678
+ }, template);
1679
+ }
1253
1680
  async function buildFreshPrompt(spec) {
1254
- let template = FRESH_BUILD_TEMPLATE;
1255
- const sectionsText = spec.sections.map((s) => `- ${s}`).join("\n");
1256
- template = template.replace("{{SPEC_JSON}}", JSON.stringify(spec, null, 2)).replace("{{THEME}}", spec.style.theme).replace("{{ACCENT_COLOR}}", spec.style.accentColor).replace("{{ANIMATION_LEVEL}}", spec.style.animationLevel).replace("{{FONT}}", spec.style.font).replace("{{SECTIONS_LIST}}", sectionsText);
1257
- return template;
1258
- }
1259
- async function buildUpdatePrompt(existingConfig, diff) {
1260
- let template = UPDATE_TEMPLATE;
1261
- const newProjectsText = diff.newProjects.length > 0 ? diff.newProjects.map(
1262
- (p6) => `- ${p6.name}: ${p6.description}
1263
- Tech: ${p6.techStack.join(", ")}
1264
- README excerpt: ${(p6.readmeContent || "").slice(0, 500)}`
1681
+ const template = await loadPromptTemplate("fresh-build.md");
1682
+ const sectionsText = spec.sections.map((section) => `- ${section}`).join("\n");
1683
+ return fillTemplate(template, {
1684
+ SPEC_JSON: JSON.stringify(spec, null, 2),
1685
+ THEME: spec.style.theme,
1686
+ ACCENT_COLOR: spec.style.accentColor,
1687
+ ANIMATION_LEVEL: spec.style.animationLevel,
1688
+ FONT: spec.style.font,
1689
+ SECTIONS_LIST: sectionsText
1690
+ });
1691
+ }
1692
+ async function buildUpdatePrompt(existingConfig, diff, preparedNewProjects = [], personalInfoDiff = "No changes") {
1693
+ const template = await loadPromptTemplate("update.md");
1694
+ const newProjects = preparedNewProjects.length > 0 ? preparedNewProjects : diff.newProjects.map((project) => ({
1695
+ ...project,
1696
+ included: true,
1697
+ overrideDescription: null,
1698
+ showSourceLink: !!project.remoteUrl,
1699
+ role: "solo",
1700
+ trackedProjectPaths: [project.localPath],
1701
+ metrics: {},
1702
+ caseStudy: {
1703
+ featured: false,
1704
+ audience: null,
1705
+ problem: null,
1706
+ solution: null,
1707
+ impact: null,
1708
+ evidence: [],
1709
+ screenshots: []
1710
+ }
1711
+ }));
1712
+ const newProjectsText = newProjects.length > 0 ? newProjects.map(
1713
+ (project) => `- ${project.name}: ${project.overrideDescription || project.description}
1714
+ Tech: ${project.techStack.join(", ")}
1715
+ Demo: ${project.demoUrl || "none"}
1716
+ Featured: ${project.caseStudy.featured}
1717
+ Case study: ${JSON.stringify(project.caseStudy)}
1718
+ README excerpt: ${(project.readmeContent || "").slice(0, 500)}`
1265
1719
  ).join("\n\n") : "None";
1266
1720
  const updatedProjectsText = diff.updatedProjects.length > 0 ? diff.updatedProjects.map(
1267
- (u) => `- ${u.project.name}: ${u.newCommits} new commits
1268
- README changed: ${u.readmeChanged}
1269
- Dependencies changed: ${u.depsChanged}`
1721
+ (update) => `- ${update.project.name}: ${update.newCommits} new commits
1722
+ README changed: ${update.readmeChanged}
1723
+ Dependencies changed: ${update.depsChanged}
1724
+ Changed paths: ${update.changedPaths.join(", ") || "none"}
1725
+ Removed paths: ${update.removedPaths.join(", ") || "none"}
1726
+ Case study: ${JSON.stringify(update.project.caseStudy)}`
1270
1727
  ).join("\n\n") : "None";
1271
- const removedProjectsText = diff.removedProjects.length > 0 ? diff.removedProjects.map((p6) => `- ${p6.name}`).join("\n") : "None";
1272
- template = template.replace(
1273
- "{{EXISTING_CONFIG_JSON}}",
1274
- JSON.stringify(existingConfig, null, 2)
1275
- ).replace("{{NEW_PROJECTS}}", newProjectsText).replace("{{UPDATED_PROJECTS}}", updatedProjectsText).replace("{{REMOVED_PROJECTS}}", removedProjectsText).replace("{{PERSONAL_INFO_DIFF}}", "No changes");
1276
- return template;
1277
- }
1278
- var FRESH_BUILD_TEMPLATE, UPDATE_TEMPLATE;
1728
+ const removedProjectsText = diff.removedProjects.length > 0 ? diff.removedProjects.map((project) => `- ${project.name}`).join("\n") : "None";
1729
+ return fillTemplate(template, {
1730
+ EXISTING_CONFIG_JSON: JSON.stringify(existingConfig, null, 2),
1731
+ NEW_PROJECTS: newProjectsText,
1732
+ UPDATED_PROJECTS: updatedProjectsText,
1733
+ REMOVED_PROJECTS: removedProjectsText,
1734
+ PERSONAL_INFO_DIFF: personalInfoDiff
1735
+ });
1736
+ }
1737
+ var promptTemplateCache;
1279
1738
  var init_prompt_builder = __esm({
1280
1739
  "src/orchestrator/prompt-builder.ts"() {
1281
1740
  "use strict";
1282
1741
  init_esm_shims();
1283
- FRESH_BUILD_TEMPLATE = `# Task
1284
-
1285
- Generate a complete, production-ready personal portfolio website.
1286
- All data and design preferences are provided in the spec below.
1287
- Output a fully working Next.js project that builds and deploys as a static site.
1288
-
1289
- # Spec
1290
-
1291
- {{SPEC_JSON}}
1292
-
1293
- # Technical Requirements
1294
-
1295
- - Next.js 15 with App Router and TypeScript
1296
- - Tailwind CSS v4 for styling
1297
- - shadcn/ui components (initialize with \`npx shadcn@latest init --yes\`)
1298
- - Static export: set \`output: 'export'\` in next.config.ts
1299
- - Build command: \`npm run build\` producing \`out/\` directory
1300
- - Zero external API calls at runtime -- all data is embedded in source
1301
- - All project data in \`src/data/projects.ts\` as typed constants
1302
- - Include \`@media print\` stylesheet in \`src/app/globals.css\` for PDF export
1303
- - Responsive: mobile-first with Tailwind breakpoints (sm, md, lg, xl)
1304
- - Lighthouse performance score 95+
1305
- - Semantic HTML with ARIA labels and keyboard navigation
1306
- - No emoji anywhere in text, UI, code, or comments
1307
- - Use lucide-react for icons
1308
-
1309
- # Design Direction
1310
-
1311
- - Theme: {{THEME}}
1312
- - Accent color: {{ACCENT_COLOR}}
1313
- - Animation level: {{ANIMATION_LEVEL}}
1314
- - Font: {{FONT}}
1315
-
1316
- Design guidelines:
1317
- - Typography-driven layout with large, bold headings
1318
- - Generous whitespace between sections
1319
- - Single accent color for interactive elements and highlights only
1320
- - Project cards should feel substantial but clean
1321
- - The site must look hand-crafted, not template-generated
1322
- - Dark themes: use zinc/slate backgrounds, not pure black
1323
- - Light themes: use warm whites and subtle grays
1324
-
1325
- # Content Generation
1326
-
1327
- For each project in the spec:
1328
- - Write a 2-3 sentence narrative description based on the README content and tech stack
1329
- - Focus on what it does and why it matters
1330
- - If user provided an override description, use that instead
1331
- - Maintain consistent voice across all descriptions
1332
-
1333
- For the bio (if set to "auto"):
1334
- - Generate a professional, authentic bio based on the project portfolio
1335
- - Emphasize shipping velocity and breadth
1336
- - Tone: confident, direct, no buzzwords or fluff
1337
-
1338
- # Sections to Include
1339
-
1340
- {{SECTIONS_LIST}}
1341
-
1342
- Hero and Projects are always included. Additional sections as specified.
1343
-
1344
- # Print / PDF Styles
1345
-
1346
- In globals.css, add @media print rules:
1347
- - Single-column layout
1348
- - Hide navigation, footer, animations
1349
- - Preserve background colors (user will print with backgrounds enabled)
1350
- - Show link URLs inline: \`a[href]::after { content: " (" attr(href) ")"; }\`
1351
- - Avoid page breaks inside project cards
1352
- - A4-friendly margins and font sizes
1353
-
1354
- # File Structure to Generate
1355
-
1356
- \`\`\`
1357
- next.config.ts
1358
- package.json
1359
- tailwind.config.ts
1360
- tsconfig.json
1361
- postcss.config.mjs
1362
- components.json
1363
- src/app/layout.tsx
1364
- src/app/page.tsx
1365
- src/app/globals.css
1366
- src/components/ui/ (shadcn components as needed)
1367
- src/components/hero.tsx
1368
- src/components/project-card.tsx
1369
- src/components/project-grid.tsx
1370
- src/components/skills.tsx
1371
- src/components/about.tsx
1372
- src/components/timeline.tsx
1373
- src/components/contact.tsx
1374
- src/components/navigation.tsx
1375
- src/components/footer.tsx
1376
- src/data/projects.ts
1377
- src/data/owner.ts
1378
- src/lib/utils.ts
1379
- public/favicon.svg
1380
- shipfolio.config.json
1381
- \`\`\`
1382
-
1383
- # Important
1384
-
1385
- - Generate ALL files needed for the project to build successfully
1386
- - Include all shadcn/ui component files that are referenced
1387
- - The \`package.json\` must include all dependencies
1388
- - \`npm install && npm run build\` must succeed without errors
1389
- - Do not use next/image (incompatible with static export) -- use standard <img> tags
1390
- - Do not use features that require a server (API routes, middleware, ISR)
1391
- `;
1392
- UPDATE_TEMPLATE = `# Task
1393
-
1394
- Update an existing portfolio website previously generated by shipfolio.
1395
- You must preserve the existing design system, layout structure, component
1396
- architecture, and any custom modifications the user has made.
1397
-
1398
- # Existing Site Configuration
1399
-
1400
- {{EXISTING_CONFIG_JSON}}
1401
-
1402
- # Changes to Apply
1403
-
1404
- ## New Projects to Add
1405
- {{NEW_PROJECTS}}
1406
-
1407
- ## Projects to Update (new commits since last scan)
1408
- {{UPDATED_PROJECTS}}
1409
-
1410
- ## Projects to Remove
1411
- {{REMOVED_PROJECTS}}
1412
-
1413
- ## Updated Personal Info
1414
- {{PERSONAL_INFO_DIFF}}
1415
-
1416
- # Rules
1417
-
1418
- 1. Do NOT change the overall layout, color scheme, or design system
1419
- 2. Do NOT reorganize existing components or rename files
1420
- 3. Only modify files that need changes for the specified updates
1421
- 4. For new projects: follow the exact same card format and component pattern
1422
- as existing project cards
1423
- 5. Preserve all custom CSS, custom components, and manual edits
1424
- 6. Update src/data/projects.ts with new/changed/removed project data
1425
- 7. Update src/data/owner.ts if personal info changed
1426
- 8. Update shipfolio.config.json with new timestamps and project list
1427
- 9. If a new section type is needed, create it following the existing
1428
- component patterns and design tokens in the codebase
1429
- 10. No emoji anywhere
1430
-
1431
- # Technical Notes
1432
-
1433
- - The site uses Next.js 15 + Tailwind CSS + shadcn/ui
1434
- - Static export via \`output: 'export'\` in next.config.ts
1435
- - Do not break the build -- \`npm run build\` must succeed
1436
- - Do not add new dependencies unless absolutely necessary
1437
- - Keep the existing @media print styles working
1438
- `;
1742
+ init_fs();
1743
+ promptTemplateCache = /* @__PURE__ */ new Map();
1439
1744
  }
1440
1745
  });
1441
1746
 
1442
1747
  // src/utils/exec.ts
1443
- import { execa } from "execa";
1444
- async function run(command, args, options) {
1748
+ import {
1749
+ execa
1750
+ } from "execa";
1751
+ function run(command, args, options) {
1445
1752
  return execa(command, args, {
1446
1753
  stdio: "pipe",
1447
1754
  ...options
@@ -1460,7 +1767,16 @@ async function runWithOutput(command, args, options) {
1460
1767
  stdio: "pipe",
1461
1768
  ...options
1462
1769
  });
1463
- return result.stdout;
1770
+ if (typeof result.stdout === "string") {
1771
+ return result.stdout;
1772
+ }
1773
+ if (result.stdout instanceof Uint8Array) {
1774
+ return Buffer.from(result.stdout).toString("utf-8");
1775
+ }
1776
+ if (Array.isArray(result.stdout)) {
1777
+ return result.stdout.join("\n");
1778
+ }
1779
+ return "";
1464
1780
  }
1465
1781
  var init_exec = __esm({
1466
1782
  "src/utils/exec.ts"() {
@@ -1537,9 +1853,11 @@ async function generateWithCodex(prompt, outputDir) {
1537
1853
  const fullPrompt = `${prompt}
1538
1854
 
1539
1855
  Create all files in the current working directory. Do not ask questions, just generate all files.`;
1540
- await execa3("codex", ["--quiet", "--full-auto", fullPrompt], {
1856
+ await execa3("codex", ["exec", "--full-auto", "--skip-git-repo-check", fullPrompt], {
1541
1857
  cwd: outputDir,
1542
- stdio: "pipe",
1858
+ stdin: "ignore",
1859
+ stdout: "ignore",
1860
+ stderr: "pipe",
1543
1861
  timeout: 6e5
1544
1862
  });
1545
1863
  spinner.succeed("Site generated with Codex");
@@ -1558,7 +1876,7 @@ var init_codex = __esm({
1558
1876
  // src/orchestrator/engines/v0.ts
1559
1877
  import OpenAI from "openai";
1560
1878
  import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
1561
- import { join as join2, dirname } from "path";
1879
+ import { join as join2, dirname as dirname2 } from "path";
1562
1880
  import ora4 from "ora";
1563
1881
  async function generateWithV0(prompt, outputDir, apiKey) {
1564
1882
  const spinner = ora4("Generating site with v0...").start();
@@ -1592,7 +1910,7 @@ async function generateWithV0(prompt, outputDir, apiKey) {
1592
1910
  spinner.text = `Writing ${files.length} files...`;
1593
1911
  for (const file of files) {
1594
1912
  const filePath = join2(outputDir, file.filename);
1595
- await mkdir2(dirname(filePath), { recursive: true });
1913
+ await mkdir2(dirname2(filePath), { recursive: true });
1596
1914
  await writeFile2(filePath, file.content, "utf-8");
1597
1915
  }
1598
1916
  const hasPkgJson = files.some((f) => f.filename === "package.json");
@@ -2310,6 +2628,44 @@ var init_draft = __esm({
2310
2628
  }
2311
2629
  });
2312
2630
 
2631
+ // src/spec/io.ts
2632
+ import { ZodError } from "zod";
2633
+ function formatValidationError(error) {
2634
+ return error.issues.map((issue) => {
2635
+ const path2 = issue.path.length > 0 ? issue.path.join(".") : "<root>";
2636
+ return `${path2}: ${issue.message}`;
2637
+ }).join("; ");
2638
+ }
2639
+ function toValidationError(label, error) {
2640
+ if (error instanceof ZodError) {
2641
+ return new Error(`${label} is invalid: ${formatValidationError(error)}`);
2642
+ }
2643
+ return error instanceof Error ? error : new Error(`${label} is invalid`);
2644
+ }
2645
+ async function readShipfolioConfig(path2) {
2646
+ try {
2647
+ return parseShipfolioConfig(await readJson(path2));
2648
+ } catch (error) {
2649
+ throw toValidationError("shipfolio.config.json", error);
2650
+ }
2651
+ }
2652
+ async function writeShipfolioConfig(path2, config) {
2653
+ const validated = parseShipfolioConfig(config);
2654
+ await writeJson(path2, validated);
2655
+ }
2656
+ async function writeShipfolioSpec(path2, spec) {
2657
+ const validated = parseShipfolioSpec(spec);
2658
+ await writeJson(path2, validated);
2659
+ }
2660
+ var init_io = __esm({
2661
+ "src/spec/io.ts"() {
2662
+ "use strict";
2663
+ init_esm_shims();
2664
+ init_fs();
2665
+ init_schema();
2666
+ }
2667
+ });
2668
+
2313
2669
  // src/commands/init.ts
2314
2670
  var init_exports = {};
2315
2671
  __export(init_exports, {
@@ -2318,8 +2674,45 @@ __export(init_exports, {
2318
2674
  import { resolve } from "path";
2319
2675
  import { join as join6 } from "path";
2320
2676
  import * as p3 from "@clack/prompts";
2677
+ function resolveEngineOption(value, available) {
2678
+ if (!value) return void 0;
2679
+ if (!["claude", "codex", "v0"].includes(value)) {
2680
+ logger.warn(`Ignoring invalid engine "${value}".`);
2681
+ return void 0;
2682
+ }
2683
+ const engine = value;
2684
+ if (!available.includes(engine)) {
2685
+ logger.warn(`Engine "${value}" is not available in the current environment.`);
2686
+ return void 0;
2687
+ }
2688
+ return engine;
2689
+ }
2690
+ function resolveDeployOption(value) {
2691
+ if (!value) return void 0;
2692
+ if (!["cloudflare", "vercel", "local"].includes(value)) {
2693
+ logger.warn(`Ignoring invalid deploy target "${value}".`);
2694
+ return void 0;
2695
+ }
2696
+ return value;
2697
+ }
2698
+ function resolveStyleOption(value) {
2699
+ if (!value) return void 0;
2700
+ if (!["dark-minimal", "light-clean", "monochrome", "custom"].includes(value)) {
2701
+ logger.warn(`Ignoring invalid theme "${value}".`);
2702
+ return void 0;
2703
+ }
2704
+ return value;
2705
+ }
2706
+ function resolveAccentOption(value) {
2707
+ if (!value) return void 0;
2708
+ if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
2709
+ logger.warn(`Ignoring invalid accent color "${value}". Expected #RRGGBB.`);
2710
+ return void 0;
2711
+ }
2712
+ return value;
2713
+ }
2321
2714
  async function initCommand(options) {
2322
- logger.header("shipfolio v1.0.9");
2715
+ logger.header("shipfolio v1.1.0");
2323
2716
  logger.info("Detecting AI engines...");
2324
2717
  const engines = await detectEngines();
2325
2718
  const availableTypes = getAvailableEngineTypes(engines);
@@ -2353,8 +2746,15 @@ async function initCommand(options) {
2353
2746
  ]);
2354
2747
  logger.table(tableRows);
2355
2748
  logger.blank();
2749
+ const interviewOptions = {
2750
+ engine: resolveEngineOption(options.engine, availableTypes),
2751
+ deploy: resolveDeployOption(options.deploy),
2752
+ style: resolveStyleOption(options.style),
2753
+ accent: resolveAccentOption(options.accent),
2754
+ auto: options.auto === true
2755
+ };
2356
2756
  let interviewResult;
2357
- const draft = await loadDraft();
2757
+ const draft = options.auto ? null : await loadDraft();
2358
2758
  if (draft) {
2359
2759
  logger.info(t().draftFound);
2360
2760
  const useDraft = await p3.confirm({
@@ -2365,10 +2765,18 @@ async function initCommand(options) {
2365
2765
  interviewResult = draft.interviewResult;
2366
2766
  await clearDraft();
2367
2767
  } else {
2368
- interviewResult = await runInterview(scannedProjects, availableTypes);
2768
+ interviewResult = await runInterview(
2769
+ scannedProjects,
2770
+ availableTypes,
2771
+ interviewOptions
2772
+ );
2369
2773
  }
2370
2774
  } else {
2371
- interviewResult = await runInterview(scannedProjects, availableTypes);
2775
+ interviewResult = await runInterview(
2776
+ scannedProjects,
2777
+ availableTypes,
2778
+ interviewOptions
2779
+ );
2372
2780
  }
2373
2781
  await saveDraft(interviewResult, true);
2374
2782
  const spec = buildSpec(interviewResult);
@@ -2384,15 +2792,9 @@ async function initCommand(options) {
2384
2792
  process.exit(1);
2385
2793
  }
2386
2794
  }
2387
- if (!options.noPdf) {
2388
- try {
2389
- await exportPdf(outputDir);
2390
- } catch {
2391
- logger.warn("PDF export skipped due to error.");
2392
- }
2393
- }
2394
2795
  let shouldDeploy = spec.deploy.platform !== "local";
2395
- if (!options.noPreview) {
2796
+ const shouldPreview = !options.noPreview && !options.auto;
2797
+ if (shouldPreview) {
2396
2798
  const server = await startPreviewServer(outputDir);
2397
2799
  logger.blank();
2398
2800
  if (shouldDeploy) {
@@ -2410,6 +2812,13 @@ async function initCommand(options) {
2410
2812
  server.close();
2411
2813
  }
2412
2814
  }
2815
+ if (!options.noPdf) {
2816
+ try {
2817
+ await exportPdf(outputDir);
2818
+ } catch {
2819
+ logger.warn("PDF export skipped due to error.");
2820
+ }
2821
+ }
2413
2822
  if (shouldDeploy) {
2414
2823
  try {
2415
2824
  const url = await deploy(
@@ -2426,7 +2835,7 @@ async function initCommand(options) {
2426
2835
  logger.info("You can retry later with: npx shipfolio deploy --site " + outputDir);
2427
2836
  }
2428
2837
  }
2429
- await writeJson(join6(outputDir, "shipfolio.config.json"), {
2838
+ await writeShipfolioConfig(join6(outputDir, "shipfolio.config.json"), {
2430
2839
  ...spec,
2431
2840
  sitePath: outputDir
2432
2841
  });
@@ -2457,6 +2866,7 @@ var init_init = __esm({
2457
2866
  init_logger();
2458
2867
  init_draft();
2459
2868
  init_i18n();
2869
+ init_io();
2460
2870
  }
2461
2871
  });
2462
2872
 
@@ -2468,38 +2878,113 @@ import { Command } from "commander";
2468
2878
  // src/commands/update.ts
2469
2879
  init_esm_shims();
2470
2880
  init_scanner();
2471
- import { resolve as resolve2, dirname as dirname2 } from "path";
2881
+ import { resolve as resolve2, dirname as dirname3 } from "path";
2472
2882
 
2473
2883
  // src/spec/diff.ts
2474
2884
  init_esm_shims();
2885
+ init_project_utils();
2475
2886
  function computeDiff(oldConfig, newScan) {
2476
- const oldProjectMap = new Map(
2477
- oldConfig.projects.map((p6) => [p6.localPath, p6])
2478
- );
2479
2887
  const newProjectMap = new Map(newScan.map((p6) => [p6.localPath, p6]));
2888
+ const trackedOldPaths = /* @__PURE__ */ new Set();
2480
2889
  const newProjects = [];
2481
2890
  const updatedProjects = [];
2482
2891
  const removedProjects = [];
2483
2892
  const unchangedProjects = [];
2484
- for (const [path2, meta] of newProjectMap) {
2485
- const oldProject = oldProjectMap.get(path2);
2486
- if (!oldProject) {
2487
- newProjects.push(meta);
2488
- } else if (meta.lastScannedCommit !== oldProject.lastScannedCommit) {
2489
- const newCommits = meta.totalCommits - oldProject.totalCommits;
2893
+ for (const oldProject of oldConfig.projects) {
2894
+ const trackedPaths = getTrackedProjectPaths(oldProject);
2895
+ for (const trackedPath of trackedPaths) {
2896
+ trackedOldPaths.add(trackedPath);
2897
+ }
2898
+ const currentMatches = trackedPaths.map((path2) => newProjectMap.get(path2)).filter((project) => Boolean(project));
2899
+ const removedPaths = trackedPaths.filter((path2) => !newProjectMap.has(path2));
2900
+ if (currentMatches.length === 0) {
2901
+ removedProjects.push(oldProject);
2902
+ continue;
2903
+ }
2904
+ if (trackedPaths.length > 1) {
2905
+ const mergedMeta = buildMergedProjectMeta(currentMatches, oldProject.name);
2906
+ const oldChildrenByPath = new Map(
2907
+ (oldProject.children || []).map((child) => [child.localPath, child])
2908
+ );
2909
+ const oldTrackingSignature = oldProject.children && oldProject.children.length > 0 ? buildTrackingSignature(oldProject.children) : oldProject.lastScannedCommit;
2910
+ let readmeChanged = removedPaths.length > 0;
2911
+ let depsChanged = removedPaths.length > 0;
2912
+ let newCommits = 0;
2913
+ for (const currentProject2 of currentMatches) {
2914
+ const oldChild = oldChildrenByPath.get(currentProject2.localPath);
2915
+ if (!oldChild) {
2916
+ readmeChanged = true;
2917
+ depsChanged = true;
2918
+ newCommits += currentProject2.totalCommits;
2919
+ continue;
2920
+ }
2921
+ if (currentProject2.readmeContent !== oldChild.readmeContent) {
2922
+ readmeChanged = true;
2923
+ }
2924
+ if (JSON.stringify(currentProject2.techStack) !== JSON.stringify(oldChild.techStack)) {
2925
+ depsChanged = true;
2926
+ }
2927
+ newCommits += Math.max(
2928
+ currentProject2.totalCommits - oldChild.totalCommits,
2929
+ 0
2930
+ );
2931
+ }
2932
+ const changedPaths = currentMatches.filter((currentProject2) => {
2933
+ const oldChild = oldChildrenByPath.get(currentProject2.localPath);
2934
+ if (!oldChild) return true;
2935
+ return currentProject2.lastScannedCommit !== oldChild.lastScannedCommit;
2936
+ }).map((project) => project.localPath);
2937
+ const hasUpdate = removedPaths.length > 0 || trackedPaths.length !== currentMatches.length || mergedMeta.lastScannedCommit !== oldTrackingSignature || readmeChanged || depsChanged;
2938
+ if (hasUpdate) {
2939
+ updatedProjects.push({
2940
+ project: createProjectEntry(mergedMeta, {
2941
+ included: oldProject.included,
2942
+ overrideDescription: oldProject.overrideDescription,
2943
+ showSourceLink: oldProject.showSourceLink,
2944
+ role: oldProject.role,
2945
+ metrics: oldProject.metrics,
2946
+ caseStudy: oldProject.caseStudy,
2947
+ trackedProjectPaths: currentMatches.map((project) => project.localPath),
2948
+ children: currentMatches
2949
+ }),
2950
+ newCommits,
2951
+ readmeChanged,
2952
+ depsChanged,
2953
+ changedPaths,
2954
+ removedPaths
2955
+ });
2956
+ } else {
2957
+ unchangedProjects.push(oldProject);
2958
+ }
2959
+ continue;
2960
+ }
2961
+ const currentProject = currentMatches[0];
2962
+ if (currentProject.lastScannedCommit !== oldProject.lastScannedCommit) {
2963
+ const newCommits = currentProject.totalCommits - oldProject.totalCommits;
2490
2964
  updatedProjects.push({
2491
- project: meta,
2965
+ project: createProjectEntry(currentProject, {
2966
+ included: oldProject.included,
2967
+ overrideDescription: oldProject.overrideDescription,
2968
+ showSourceLink: oldProject.showSourceLink,
2969
+ role: oldProject.role,
2970
+ metrics: oldProject.metrics,
2971
+ caseStudy: oldProject.caseStudy,
2972
+ trackedProjectPaths: trackedPaths,
2973
+ children: oldProject.children
2974
+ }),
2492
2975
  newCommits: Math.max(newCommits, 0),
2493
- readmeChanged: meta.readmeContent !== oldProject.readmeContent,
2494
- depsChanged: JSON.stringify(meta.techStack) !== JSON.stringify(oldProject.techStack)
2976
+ readmeChanged: currentProject.readmeContent !== oldProject.readmeContent,
2977
+ depsChanged: JSON.stringify(currentProject.techStack) !== JSON.stringify(oldProject.techStack),
2978
+ changedPaths: [currentProject.localPath],
2979
+ removedPaths
2495
2980
  });
2496
2981
  } else {
2497
2982
  unchangedProjects.push(oldProject);
2498
2983
  }
2499
2984
  }
2500
- for (const [path2, project] of oldProjectMap) {
2501
- if (!newProjectMap.has(path2)) {
2502
- removedProjects.push(project);
2985
+ for (const [path2, meta] of newProjectMap) {
2986
+ if (!trackedOldPaths.has(path2)) {
2987
+ newProjects.push(meta);
2503
2988
  }
2504
2989
  }
2505
2990
  return {
@@ -2518,7 +3003,54 @@ init_deployer();
2518
3003
  init_pdf();
2519
3004
  init_fs();
2520
3005
  init_logger();
3006
+ init_io();
3007
+ init_project_utils();
3008
+ init_questions();
3009
+ init_interviewer();
3010
+ init_i18n();
2521
3011
  import * as p4 from "@clack/prompts";
3012
+ function parseCommaSeparatedList2(value) {
3013
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
3014
+ }
3015
+ function buildPersonalInfoDiff(previousOwner, nextOwner, previousSections, nextSections) {
3016
+ const changes = [];
3017
+ if (previousOwner.name !== nextOwner.name) {
3018
+ changes.push(`Name: ${previousOwner.name || "none"} -> ${nextOwner.name || "none"}`);
3019
+ }
3020
+ if (previousOwner.tagline !== nextOwner.tagline) {
3021
+ changes.push(`Tagline: ${previousOwner.tagline || "none"} -> ${nextOwner.tagline || "none"}`);
3022
+ }
3023
+ if (previousOwner.bio !== nextOwner.bio) {
3024
+ changes.push("Bio updated");
3025
+ }
3026
+ if (previousOwner.photoUrl !== nextOwner.photoUrl) {
3027
+ changes.push(`Photo URL: ${previousOwner.photoUrl || "none"} -> ${nextOwner.photoUrl || "none"}`);
3028
+ }
3029
+ const socialKeys = [
3030
+ "github",
3031
+ "twitter",
3032
+ "linkedin",
3033
+ "blog",
3034
+ "email"
3035
+ ];
3036
+ for (const key of socialKeys) {
3037
+ if (previousOwner.social[key] !== nextOwner.social[key]) {
3038
+ changes.push(`${key}: ${previousOwner.social[key] || "none"} -> ${nextOwner.social[key] || "none"}`);
3039
+ }
3040
+ }
3041
+ if (JSON.stringify(previousSections) !== JSON.stringify(nextSections)) {
3042
+ changes.push(`Sections: ${previousSections.join(", ")} -> ${nextSections.join(", ")}`);
3043
+ }
3044
+ if (changes.length === 0) {
3045
+ return "No changes";
3046
+ }
3047
+ return [
3048
+ ...changes,
3049
+ "",
3050
+ `Final owner: ${JSON.stringify(nextOwner, null, 2)}`,
3051
+ `Final sections: ${nextSections.join(", ")}`
3052
+ ].join("\n");
3053
+ }
2522
3054
  async function updateCommand(options) {
2523
3055
  logger.header("shipfolio update");
2524
3056
  const siteDir = resolve2(options.site);
@@ -2538,7 +3070,7 @@ async function updateCommand(options) {
2538
3070
  await initCommand2({ output: siteDir });
2539
3071
  return;
2540
3072
  }
2541
- const config = await readJson(configPath);
3073
+ const config = await readShipfolioConfig(configPath);
2542
3074
  const generatedDate = config.generatedAt?.slice(0, 10) || "unknown";
2543
3075
  logger.info(
2544
3076
  `Site: ${config.deploy.projectName || "local"} (generated ${generatedDate})`
@@ -2551,7 +3083,13 @@ async function updateCommand(options) {
2551
3083
  logger.error("No AI engine found. See `npx shipfolio` for install instructions.");
2552
3084
  process.exit(1);
2553
3085
  }
2554
- const scanDirs = options.scan?.map((d) => resolve2(d)) || [...new Set(config.projects.map((p6) => dirname2(p6.localPath)))];
3086
+ const scanDirs = options.scan?.map((d) => resolve2(d)) || [
3087
+ ...new Set(
3088
+ config.projects.flatMap(
3089
+ (project) => getTrackedProjectPaths(project).map((path2) => dirname3(path2))
3090
+ )
3091
+ )
3092
+ ];
2555
3093
  const scannedProjects = await scanProjects(scanDirs);
2556
3094
  const diff = computeDiff(config, scannedProjects);
2557
3095
  logger.header("Changes detected:");
@@ -2564,9 +3102,16 @@ async function updateCommand(options) {
2564
3102
  if (diff.updatedProjects.length > 0) {
2565
3103
  logger.plain(" Updated:");
2566
3104
  for (const upd of diff.updatedProjects) {
3105
+ const changedSummary = upd.changedPaths.length > 0 ? ` paths: ${upd.changedPaths.join(", ")}` : "";
2567
3106
  logger.plain(
2568
3107
  ` ~ ${upd.project.name} ${upd.newCommits} new commits`
2569
3108
  );
3109
+ if (changedSummary) {
3110
+ logger.plain(changedSummary);
3111
+ }
3112
+ if (upd.removedPaths.length > 0) {
3113
+ logger.plain(` removed: ${upd.removedPaths.join(", ")}`);
3114
+ }
2570
3115
  }
2571
3116
  }
2572
3117
  if (diff.removedProjects.length > 0) {
@@ -2586,8 +3131,251 @@ async function updateCommand(options) {
2586
3131
  if (p4.isCancel(proceed) || !proceed) {
2587
3132
  process.exit(0);
2588
3133
  }
3134
+ const preparedNewProjects = [];
3135
+ for (const project of diff.newProjects) {
3136
+ logger.plain(`
3137
+ ${t().configuring(project.name)}`);
3138
+ const overrideDesc = await p4.text({
3139
+ message: t().descriptionFor(project.name),
3140
+ placeholder: project.description?.slice(0, 80) || t().descriptionPlaceholder,
3141
+ defaultValue: ""
3142
+ });
3143
+ if (p4.isCancel(overrideDesc)) process.exit(0);
3144
+ const demoUrl = await p4.text({
3145
+ message: t().demoUrlFor(project.name),
3146
+ placeholder: project.demoUrl || "none",
3147
+ defaultValue: project.demoUrl || ""
3148
+ });
3149
+ if (p4.isCancel(demoUrl)) process.exit(0);
3150
+ const showSource = await p4.confirm({
3151
+ message: t().showSourceFor(project.name),
3152
+ initialValue: !!project.remoteUrl
3153
+ });
3154
+ if (p4.isCancel(showSource)) process.exit(0);
3155
+ const role = await p4.select({
3156
+ message: t().roleFor(project.name),
3157
+ options: ROLE_OPTIONS()
3158
+ });
3159
+ if (p4.isCancel(role)) process.exit(0);
3160
+ const metricsInput = await p4.text({
3161
+ message: t().metricsFor(project.name),
3162
+ placeholder: t().optional,
3163
+ defaultValue: ""
3164
+ });
3165
+ if (p4.isCancel(metricsInput)) process.exit(0);
3166
+ const featured = await p4.confirm({
3167
+ message: t().featureProjectFor(project.name),
3168
+ initialValue: false
3169
+ });
3170
+ if (p4.isCancel(featured)) process.exit(0);
3171
+ const addCaseStudyDetails = await p4.confirm({
3172
+ message: t().caseStudyDetailsFor(project.name),
3173
+ initialValue: false
3174
+ });
3175
+ if (p4.isCancel(addCaseStudyDetails)) process.exit(0);
3176
+ let audience = null;
3177
+ let problem = null;
3178
+ let solution = null;
3179
+ let impact = null;
3180
+ let evidence = [];
3181
+ let screenshots = [];
3182
+ if (addCaseStudyDetails) {
3183
+ const audienceInput = await p4.text({
3184
+ message: t().audienceFor(project.name),
3185
+ placeholder: t().optional,
3186
+ defaultValue: ""
3187
+ });
3188
+ if (p4.isCancel(audienceInput)) process.exit(0);
3189
+ audience = audienceInput || null;
3190
+ const problemInput = await p4.text({
3191
+ message: t().problemFor(project.name),
3192
+ placeholder: t().optional,
3193
+ defaultValue: ""
3194
+ });
3195
+ if (p4.isCancel(problemInput)) process.exit(0);
3196
+ problem = problemInput || null;
3197
+ const solutionInput = await p4.text({
3198
+ message: t().solutionFor(project.name),
3199
+ placeholder: t().optional,
3200
+ defaultValue: ""
3201
+ });
3202
+ if (p4.isCancel(solutionInput)) process.exit(0);
3203
+ solution = solutionInput || null;
3204
+ const impactInput = await p4.text({
3205
+ message: t().impactFor(project.name),
3206
+ placeholder: t().optional,
3207
+ defaultValue: metricsInput || ""
3208
+ });
3209
+ if (p4.isCancel(impactInput)) process.exit(0);
3210
+ impact = impactInput || null;
3211
+ const evidenceInput = await p4.text({
3212
+ message: t().evidenceFor(project.name),
3213
+ placeholder: t().optional,
3214
+ defaultValue: ""
3215
+ });
3216
+ if (p4.isCancel(evidenceInput)) process.exit(0);
3217
+ evidence = parseCommaSeparatedList2(evidenceInput);
3218
+ const screenshotsInput = await p4.text({
3219
+ message: t().screenshotsFor(project.name),
3220
+ placeholder: t().optional,
3221
+ defaultValue: ""
3222
+ });
3223
+ if (p4.isCancel(screenshotsInput)) process.exit(0);
3224
+ screenshots = parseCommaSeparatedList2(screenshotsInput);
3225
+ }
3226
+ const metrics = metricsInput ? { custom: { summary: metricsInput } } : {};
3227
+ preparedNewProjects.push(
3228
+ createProjectEntry(
3229
+ {
3230
+ ...project,
3231
+ demoUrl: demoUrl || project.demoUrl
3232
+ },
3233
+ {
3234
+ overrideDescription: overrideDesc || null,
3235
+ showSourceLink: showSource,
3236
+ role,
3237
+ metrics,
3238
+ caseStudy: {
3239
+ ...defaultCaseStudy(featured),
3240
+ audience,
3241
+ problem,
3242
+ solution,
3243
+ impact,
3244
+ evidence,
3245
+ screenshots
3246
+ }
3247
+ }
3248
+ )
3249
+ );
3250
+ }
3251
+ let nextOwner = config.owner;
3252
+ const shouldUpdateProfile = await p4.confirm({
3253
+ message: t().updateProfilePrompt,
3254
+ initialValue: false
3255
+ });
3256
+ if (p4.isCancel(shouldUpdateProfile)) {
3257
+ process.exit(0);
3258
+ }
3259
+ if (shouldUpdateProfile) {
3260
+ const name = await p4.text({
3261
+ message: t().fullName,
3262
+ defaultValue: config.owner.name,
3263
+ validate: (v) => v.length === 0 ? t().nameRequired : void 0
3264
+ });
3265
+ if (p4.isCancel(name)) process.exit(0);
3266
+ const tagline = await p4.text({
3267
+ message: t().tagline,
3268
+ placeholder: t().taglinePlaceholder,
3269
+ defaultValue: config.owner.tagline
3270
+ });
3271
+ if (p4.isCancel(tagline)) process.exit(0);
3272
+ const bioChoice = await p4.select({
3273
+ message: t().bio,
3274
+ options: [
3275
+ { value: "auto", label: t().bioAuto },
3276
+ { value: "manual", label: t().bioManual }
3277
+ ],
3278
+ initialValue: config.owner.bio === "auto" ? "auto" : "manual"
3279
+ });
3280
+ if (p4.isCancel(bioChoice)) process.exit(0);
3281
+ let bio = "auto";
3282
+ if (bioChoice === "manual") {
3283
+ bio = await p4.text({
3284
+ message: t().bioPrompt,
3285
+ defaultValue: config.owner.bio === "auto" ? "" : config.owner.bio
3286
+ });
3287
+ if (p4.isCancel(bio)) process.exit(0);
3288
+ }
3289
+ const photoUrl = await p4.text({
3290
+ message: t().photoUrl,
3291
+ placeholder: "https://...",
3292
+ defaultValue: config.owner.photoUrl || ""
3293
+ });
3294
+ if (p4.isCancel(photoUrl)) process.exit(0);
3295
+ const github = await p4.text({
3296
+ message: t().githubUser,
3297
+ defaultValue: config.owner.social.github || ""
3298
+ });
3299
+ if (p4.isCancel(github)) process.exit(0);
3300
+ const twitter = await p4.text({
3301
+ message: t().twitterHandle,
3302
+ defaultValue: config.owner.social.twitter || ""
3303
+ });
3304
+ if (p4.isCancel(twitter)) process.exit(0);
3305
+ const linkedin = await p4.text({
3306
+ message: t().linkedinUrl,
3307
+ defaultValue: config.owner.social.linkedin || ""
3308
+ });
3309
+ if (p4.isCancel(linkedin)) process.exit(0);
3310
+ const blog = await p4.text({
3311
+ message: t().blogUrl,
3312
+ defaultValue: config.owner.social.blog || ""
3313
+ });
3314
+ if (p4.isCancel(blog)) process.exit(0);
3315
+ const email = await p4.text({
3316
+ message: t().contactEmail,
3317
+ defaultValue: config.owner.social.email || ""
3318
+ });
3319
+ if (p4.isCancel(email)) process.exit(0);
3320
+ nextOwner = {
3321
+ name,
3322
+ tagline,
3323
+ bio,
3324
+ photoUrl: photoUrl || null,
3325
+ social: {
3326
+ github: github || void 0,
3327
+ twitter: twitter || void 0,
3328
+ linkedin: linkedin || void 0,
3329
+ blog: blog || void 0,
3330
+ email: email || void 0
3331
+ }
3332
+ };
3333
+ }
3334
+ const projectedProjects = [
3335
+ ...diff.unchangedProjects,
3336
+ ...diff.updatedProjects.map((update) => update.project),
3337
+ ...preparedNewProjects
3338
+ ];
3339
+ let nextSections = config.sections;
3340
+ const shouldReviewSections = await p4.confirm({
3341
+ message: t().reviewSectionsPrompt,
3342
+ initialValue: shouldUpdateProfile
3343
+ });
3344
+ if (p4.isCancel(shouldReviewSections)) {
3345
+ process.exit(0);
3346
+ }
3347
+ if (shouldReviewSections) {
3348
+ const sectionOptions = getAdditionalSectionOptions(projectedProjects, nextOwner);
3349
+ const currentAdditionalSections = config.sections.filter(
3350
+ (section) => section !== "hero" && section !== "projects"
3351
+ );
3352
+ const allowedCurrentValues = currentAdditionalSections.filter(
3353
+ (section) => sectionOptions.some((option) => option.value === section)
3354
+ );
3355
+ const additionalSections = sectionOptions.length > 0 ? await p4.multiselect({
3356
+ message: t().additionalSections,
3357
+ options: sectionOptions,
3358
+ required: false,
3359
+ initialValues: allowedCurrentValues
3360
+ }) : [];
3361
+ if (sectionOptions.length > 0 && p4.isCancel(additionalSections)) {
3362
+ process.exit(0);
3363
+ }
3364
+ nextSections = ["hero", "projects", ...additionalSections];
3365
+ }
3366
+ const personalInfoDiff = buildPersonalInfoDiff(
3367
+ config.owner,
3368
+ nextOwner,
3369
+ config.sections,
3370
+ nextSections
3371
+ );
2589
3372
  const engine = availableTypes.includes(config.engine) ? config.engine : availableTypes[0];
2590
- const prompt = await buildUpdatePrompt(config, diff);
3373
+ const prompt = await buildUpdatePrompt(
3374
+ config,
3375
+ diff,
3376
+ preparedNewProjects,
3377
+ personalInfoDiff
3378
+ );
2591
3379
  await generateSite(engine, prompt, siteDir);
2592
3380
  let buildResult = await buildSite(siteDir);
2593
3381
  if (!buildResult.success) {
@@ -2627,34 +3415,15 @@ async function updateCommand(options) {
2627
3415
  const updatedConfig = {
2628
3416
  ...config,
2629
3417
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3418
+ owner: nextOwner,
3419
+ sections: nextSections,
2630
3420
  projects: [
2631
3421
  ...diff.unchangedProjects,
2632
- ...diff.updatedProjects.map((u) => {
2633
- const existing = config.projects.find(
2634
- (p6) => p6.localPath === u.project.localPath
2635
- );
2636
- return {
2637
- ...u.project,
2638
- included: true,
2639
- overrideDescription: existing?.overrideDescription || null,
2640
- showSourceLink: existing?.showSourceLink ?? !!u.project.remoteUrl,
2641
- role: existing?.role || "solo",
2642
- metrics: existing?.metrics || {}
2643
- };
2644
- }),
2645
- ...diff.newProjects.map(
2646
- (proj) => ({
2647
- ...proj,
2648
- included: true,
2649
- overrideDescription: null,
2650
- showSourceLink: !!proj.remoteUrl,
2651
- role: "solo",
2652
- metrics: {}
2653
- })
2654
- )
3422
+ ...diff.updatedProjects.map((u) => u.project),
3423
+ ...preparedNewProjects
2655
3424
  ]
2656
3425
  };
2657
- await writeJson(join(siteDir, "shipfolio.config.json"), updatedConfig);
3426
+ await writeShipfolioConfig(join(siteDir, "shipfolio.config.json"), updatedConfig);
2658
3427
  logger.success("Update complete.");
2659
3428
  }
2660
3429
 
@@ -2663,6 +3432,7 @@ init_esm_shims();
2663
3432
  init_scanner();
2664
3433
  init_interviewer();
2665
3434
  init_builder();
3435
+ init_io();
2666
3436
  init_prompt_builder();
2667
3437
  init_detect();
2668
3438
  init_fs();
@@ -2691,7 +3461,7 @@ async function specCommand(options) {
2691
3461
  const outputDir = options.output || ".";
2692
3462
  const specPath = resolve3(outputDir, "shipfolio-spec.json");
2693
3463
  const promptPath = resolve3(outputDir, "shipfolio-prompt.md");
2694
- await writeJson(specPath, spec);
3464
+ await writeShipfolioSpec(specPath, spec);
2695
3465
  await writeText(promptPath, prompt);
2696
3466
  logger.success(`Spec saved: ${specPath}`);
2697
3467
  logger.success(`Prompt saved: ${promptPath}`);
@@ -2706,6 +3476,7 @@ init_esm_shims();
2706
3476
  init_deployer();
2707
3477
  init_fs();
2708
3478
  init_logger();
3479
+ init_io();
2709
3480
  import { resolve as resolve4 } from "path";
2710
3481
  import * as p5 from "@clack/prompts";
2711
3482
  async function deployCommand(options) {
@@ -2715,7 +3486,7 @@ async function deployCommand(options) {
2715
3486
  let projectName;
2716
3487
  let customDomain;
2717
3488
  if (await fileExists(configPath)) {
2718
- const config = await readJson(configPath);
3489
+ const config = await readShipfolioConfig(configPath);
2719
3490
  platform = options.platform || config.deploy.platform;
2720
3491
  projectName = config.deploy.projectName;
2721
3492
  customDomain = config.deploy.customDomain;
@@ -2785,8 +3556,8 @@ initLocale();
2785
3556
  var program = new Command();
2786
3557
  program.name("shipfolio").description(
2787
3558
  "Generate and deploy your personal portfolio site from local projects using AI"
2788
- ).version("1.0.9");
2789
- program.command("init", { isDefault: true }).description("Generate a new portfolio site").option("-s, --scan <dirs...>", "Directories to scan for projects").option("-o, --output <dir>", "Output directory", "./shipfolio-site").option("--no-pdf", "Skip PDF export").option("--no-preview", "Skip local preview").action(initCommand);
3559
+ ).version("1.1.0");
3560
+ program.command("init", { isDefault: true }).description("Generate a new portfolio site").option("-s, --scan <dirs...>", "Directories to scan for projects").option("-e, --engine <engine>", "AI engine: claude | codex | v0").option("-d, --deploy <platform>", "Deploy target: cloudflare | vercel | local").option("--style <theme>", "Theme: dark-minimal | light-clean | monochrome | custom").option("--accent <hex>", "Accent color (hex)").option("--auto", "Skip prompts and use safe defaults").option("-o, --output <dir>", "Output directory", "./shipfolio-site").option("--no-pdf", "Skip PDF export").option("--no-preview", "Skip local preview").action(initCommand);
2790
3561
  program.command("update").description("Update an existing portfolio site").requiredOption("--site <path>", "Path to existing site directory").option("-s, --scan <dirs...>", "Directories to scan for projects").option("--no-pdf", "Skip PDF export").option("--no-preview", "Skip preview").option("--no-deploy", "Skip deployment").action(updateCommand);
2791
3562
  program.command("spec").description("Generate spec and prompt files only").option("-s, --scan <dirs...>", "Directories to scan for projects").option("-o, --output <dir>", "Output directory for spec files", ".").action(specCommand);
2792
3563
  program.command("deploy").description("Deploy an existing built site").requiredOption("--site <path>", "Path to site directory").option("-p, --platform <platform>", "Deploy platform: cloudflare | vercel").action(deployCommand);