@forwardimpact/pathway 0.12.0 → 0.14.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 (54) hide show
  1. package/bin/fit-pathway.js +32 -12
  2. package/package.json +3 -3
  3. package/src/commands/build.js +98 -2
  4. package/src/commands/index.js +1 -0
  5. package/src/commands/interview.js +52 -14
  6. package/src/commands/job.js +1 -0
  7. package/src/commands/questions.js +13 -10
  8. package/src/commands/stage.js +8 -8
  9. package/src/commands/update.js +133 -0
  10. package/src/components/command-prompt.js +85 -0
  11. package/src/components/nav.js +2 -2
  12. package/src/components/top-bar.js +97 -0
  13. package/src/css/bundles/app.css +2 -0
  14. package/src/css/components/badges.css +41 -11
  15. package/src/css/components/command-prompt.css +98 -0
  16. package/src/css/components/layout.css +0 -3
  17. package/src/css/components/nav.css +121 -81
  18. package/src/css/components/surfaces.css +1 -1
  19. package/src/css/components/top-bar.css +180 -0
  20. package/src/css/pages/agent-builder.css +0 -9
  21. package/src/css/pages/landing.css +4 -0
  22. package/src/css/pages/lifecycle.css +5 -2
  23. package/src/css/reset.css +1 -1
  24. package/src/css/tokens.css +25 -11
  25. package/src/css/views/slide-base.css +2 -1
  26. package/src/formatters/agent/dom.js +0 -26
  27. package/src/formatters/agent/profile.js +13 -7
  28. package/src/formatters/agent/skill.js +4 -4
  29. package/src/formatters/interview/markdown.js +62 -3
  30. package/src/formatters/interview/shared.js +89 -52
  31. package/src/formatters/questions/markdown.js +15 -0
  32. package/src/formatters/questions/shared.js +70 -58
  33. package/src/formatters/stage/dom.js +13 -10
  34. package/src/formatters/stage/microdata.js +14 -8
  35. package/src/formatters/stage/shared.js +4 -4
  36. package/src/index.html +69 -44
  37. package/src/lib/cli-command.js +145 -0
  38. package/src/lib/state.js +2 -0
  39. package/src/lib/yaml-loader.js +39 -21
  40. package/src/main.js +47 -26
  41. package/src/pages/agent-builder.js +0 -28
  42. package/src/pages/behaviour.js +3 -1
  43. package/src/pages/discipline.js +3 -1
  44. package/src/pages/driver.js +6 -1
  45. package/src/pages/grade.js +6 -1
  46. package/src/pages/interview.js +61 -5
  47. package/src/pages/job.js +1 -0
  48. package/src/pages/landing.js +7 -0
  49. package/src/pages/skill.js +9 -2
  50. package/src/pages/track.js +6 -1
  51. package/src/slides/job.js +1 -0
  52. package/templates/agent.template.md +17 -10
  53. package/templates/install.template.sh +33 -0
  54. package/templates/skill.template.md +15 -7
@@ -18,7 +18,7 @@
18
18
  * stage [<id>] Show stages
19
19
  * tool [<name>] Show tools
20
20
  * job [<discipline> <grade>] [--track=TRACK] Generate job definition
21
- * interview <discipline> <grade> [--track=TRACK] [--type=TYPE] Generate interview
21
+ * interview <discipline> <grade> [--track=TRACK] [--type=mission|decomposition|stakeholder] Generate interview
22
22
  * progress <discipline> <grade> [--track=TRACK] [--compare=GRADE] Career progression
23
23
  * questions [options] Browse interview questions
24
24
  * agent [<discipline> <track>] [--output=PATH] Generate AI agent
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { join, resolve } from "path";
33
33
  import { existsSync } from "fs";
34
+ import { homedir } from "os";
34
35
  import { loadAllData } from "@forwardimpact/schema/loader";
35
36
  import { formatError } from "../src/lib/cli-output.js";
36
37
 
@@ -51,6 +52,7 @@ import { runAgentCommand } from "../src/commands/agent.js";
51
52
  import { runDevCommand } from "../src/commands/dev.js";
52
53
  import { runInitCommand } from "../src/commands/init.js";
53
54
  import { runBuildCommand } from "../src/commands/build.js";
55
+ import { runUpdateCommand } from "../src/commands/update.js";
54
56
 
55
57
  const COMMANDS = {
56
58
  discipline: runDisciplineCommand,
@@ -86,7 +88,8 @@ GETTING STARTED
86
88
 
87
89
  init Create ./data/ with example data
88
90
  dev [--port=PORT] Run live development server
89
- build [--output=PATH] Generate static site to ./public/
91
+ build [--output=PATH] [--url=URL] Generate static site + distribution bundle
92
+ update [--url=URL] Update local ~/.fit/pathway/ installation
90
93
 
91
94
  ────────────────────────────────────────────────────────────────────────────────
92
95
  ENTITY COMMANDS
@@ -167,13 +170,14 @@ INTERVIEW COMMAND
167
170
  Generate interview question sets based on job requirements.
168
171
 
169
172
  Usage:
170
- npx fit-pathway interview <discipline> <grade>
171
- npx fit-pathway interview <d> <g> --track=<track>
172
- npx fit-pathway interview <d> <g> --type=<type>
173
+ npx fit-pathway interview <discipline> <grade> All types
174
+ npx fit-pathway interview <d> <g> --track=<track> With track
175
+ npx fit-pathway interview <d> <g> --track=<t> --type=<type> Single type
173
176
 
174
177
  Options:
175
178
  --track=TRACK Track specialization
176
- --type=TYPE Interview type: full (default), short
179
+ --type=TYPE Interview type: mission, decomposition, stakeholder
180
+ (omit for all types)
177
181
 
178
182
  ────────────────────────────────────────────────────────────────────────────────
179
183
  PROGRESS COMMAND
@@ -252,6 +256,7 @@ function parseArgs(args) {
252
256
  path: null,
253
257
  // Site command options
254
258
  clean: true,
259
+ url: null,
255
260
  };
256
261
 
257
262
  for (const arg of args) {
@@ -307,6 +312,8 @@ function parseArgs(args) {
307
312
  result.path = arg.slice(7);
308
313
  } else if (arg === "--no-clean") {
309
314
  result.clean = false;
315
+ } else if (arg.startsWith("--url=")) {
316
+ result.url = arg.slice(6);
310
317
  } else if (!arg.startsWith("-")) {
311
318
  if (!result.command) {
312
319
  result.command = arg;
@@ -331,9 +338,10 @@ function printHelp() {
331
338
  * Resolution order:
332
339
  * 1. --data=<path> flag (explicit override)
333
340
  * 2. PATHWAY_DATA environment variable
334
- * 3. ./data/ relative to current working directory
335
- * 4. ./examples/ relative to current working directory
336
- * 5. apps/schema/examples/ for monorepo development
341
+ * 3. ~/.fit/pathway/data/ (home directory install)
342
+ * 4. ./data/ relative to current working directory
343
+ * 5. ./examples/ relative to current working directory
344
+ * 6. apps/schema/examples/ for monorepo development
337
345
  *
338
346
  * @param {Object} options - Parsed command options
339
347
  * @returns {string} Resolved absolute path to data directory
@@ -349,19 +357,25 @@ function resolveDataPath(options) {
349
357
  return resolve(process.env.PATHWAY_DATA);
350
358
  }
351
359
 
352
- // 3. Current working directory ./data/
360
+ // 3. Home directory install (~/.fit/pathway/data/)
361
+ const homeData = join(homedir(), ".fit", "pathway", "data");
362
+ if (existsSync(homeData)) {
363
+ return homeData;
364
+ }
365
+
366
+ // 4. Current working directory ./data/
353
367
  const cwdData = join(process.cwd(), "data");
354
368
  if (existsSync(cwdData)) {
355
369
  return cwdData;
356
370
  }
357
371
 
358
- // 4. Current working directory ./examples/
372
+ // 5. Current working directory ./examples/
359
373
  const cwdExamples = join(process.cwd(), "examples");
360
374
  if (existsSync(cwdExamples)) {
361
375
  return cwdExamples;
362
376
  }
363
377
 
364
- // 5. Monorepo: apps/schema/examples/
378
+ // 6. Monorepo: apps/schema/examples/
365
379
  const schemaExamples = join(process.cwd(), "apps/schema/examples");
366
380
  if (existsSync(schemaExamples)) {
367
381
  return schemaExamples;
@@ -413,6 +427,12 @@ async function main() {
413
427
  process.exit(0);
414
428
  }
415
429
 
430
+ // Handle update command (re-downloads bundle for local install)
431
+ if (command === "update") {
432
+ await runUpdateCommand({ dataDir, options });
433
+ process.exit(0);
434
+ }
435
+
416
436
  const handler = COMMANDS[command];
417
437
 
418
438
  if (!handler) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/pathway",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Career progression web app and CLI for exploring roles and generating agents",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "./commands": "./src/commands/index.js"
41
41
  },
42
42
  "dependencies": {
43
- "@forwardimpact/schema": "^0.5.0",
44
- "@forwardimpact/model": "^0.6.0",
43
+ "@forwardimpact/schema": "^0.6.0",
44
+ "@forwardimpact/model": "^0.8.0",
45
45
  "mustache": "^4.2.0",
46
46
  "simple-icons": "^16.7.0",
47
47
  "yaml": "^2.3.4"
@@ -3,11 +3,24 @@
3
3
  *
4
4
  * Generates a static site from the Engineering Pathway data.
5
5
  * Copies all necessary files (HTML, JS, CSS) and data to an output directory.
6
+ * Optionally generates a distribution bundle (bundle.tar.gz + install.sh)
7
+ * for local installs by individual engineers.
6
8
  */
7
9
 
8
- import { cp, mkdir, rm, access, realpath } from "fs/promises";
10
+ import {
11
+ cp,
12
+ mkdir,
13
+ rm,
14
+ access,
15
+ realpath,
16
+ readFile,
17
+ writeFile,
18
+ } from "fs/promises";
19
+ import { readFileSync } from "fs";
9
20
  import { join, dirname, relative, resolve } from "path";
10
21
  import { fileURLToPath } from "url";
22
+ import { execFileSync } from "child_process";
23
+ import Mustache from "mustache";
11
24
  import { generateAllIndexes } from "@forwardimpact/schema/index-generator";
12
25
  import { loadFrameworkConfig } from "@forwardimpact/schema/loader";
13
26
 
@@ -151,14 +164,97 @@ ${framework.emojiIcon} Generating ${framework.title} static site...
151
164
  console.log(` ✓ data/ (from ${relative(process.cwd(), dataDir)})`);
152
165
  }
153
166
 
167
+ // Generate distribution bundle if siteUrl is configured
168
+ const siteUrl = options.url || framework.distribution?.siteUrl;
169
+ if (siteUrl) {
170
+ await generateBundle({ outputDir, dataDir, siteUrl, framework });
171
+ }
172
+
154
173
  // Show summary
155
174
  console.log(`
156
175
  ✅ Site generated successfully!
157
176
 
158
177
  Output: ${outputDir}
159
-
178
+ ${siteUrl ? `\nDistribution:\n ${outputDir}/bundle.tar.gz\n ${outputDir}/install.sh\n` : ""}
160
179
  To serve locally:
161
180
  cd ${relative(process.cwd(), outputDir) || "."}
162
181
  npx serve .
163
182
  `);
164
183
  }
184
+
185
+ /**
186
+ * Read the pathway package version from package.json
187
+ * @returns {string} Package version
188
+ */
189
+ function getPathwayVersion() {
190
+ const pkgPath = join(appDir, "..", "package.json");
191
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
192
+ return pkg.version;
193
+ }
194
+
195
+ /**
196
+ * Generate distribution bundle (bundle.tar.gz + install.sh)
197
+ * @param {Object} params
198
+ * @param {string} params.outputDir - Build output directory
199
+ * @param {string} params.dataDir - Source data directory
200
+ * @param {string} params.siteUrl - Base URL for the published site
201
+ * @param {Object} params.framework - Framework configuration
202
+ */
203
+ async function generateBundle({ outputDir, dataDir, siteUrl, framework }) {
204
+ console.log("📦 Generating distribution bundle...");
205
+
206
+ const version = getPathwayVersion();
207
+ const frameworkTitle = framework.title || "Engineering Pathway";
208
+
209
+ // 1. Create temporary bundle directory
210
+ const bundleDir = join(outputDir, "_bundle");
211
+ await mkdir(bundleDir, { recursive: true });
212
+
213
+ // 2. Generate minimal package.json for the bundle
214
+ const bundlePkg = {
215
+ name: "fit-pathway-local",
216
+ version: version,
217
+ private: true,
218
+ dependencies: {
219
+ "@forwardimpact/pathway": `^${version}`,
220
+ },
221
+ };
222
+ await writeFile(
223
+ join(bundleDir, "package.json"),
224
+ JSON.stringify(bundlePkg, null, 2) + "\n",
225
+ );
226
+ console.log(` ✓ package.json (pathway ^${version})`);
227
+
228
+ // 3. Copy data files into bundle
229
+ await cp(dataDir, join(bundleDir, "data"), {
230
+ recursive: true,
231
+ dereference: true,
232
+ });
233
+ console.log(" ✓ data/");
234
+
235
+ // 4. Create tar.gz from the bundle directory
236
+ execFileSync("tar", [
237
+ "-czf",
238
+ join(outputDir, "bundle.tar.gz"),
239
+ "-C",
240
+ outputDir,
241
+ "_bundle",
242
+ ]);
243
+ console.log(" ✓ bundle.tar.gz");
244
+
245
+ // 5. Clean up temporary bundle directory
246
+ await rm(bundleDir, { recursive: true });
247
+
248
+ // 6. Render install.sh from template
249
+ const templatePath = join(appDir, "..", "templates", "install.template.sh");
250
+ const template = await readFile(templatePath, "utf8");
251
+ const installScript = Mustache.render(template, {
252
+ siteUrl: siteUrl.replace(/\/$/, ""),
253
+ version,
254
+ frameworkTitle,
255
+ });
256
+ await writeFile(join(outputDir, "install.sh"), installScript, {
257
+ mode: 0o755,
258
+ });
259
+ console.log(" ✓ install.sh");
260
+ }
@@ -19,3 +19,4 @@ export { runQuestionsCommand } from "./questions.js";
19
19
  export { runServeCommand } from "./serve.js";
20
20
  export { runInitCommand } from "./init.js";
21
21
  export { runSiteCommand } from "./site.js";
22
+ export { runUpdateCommand } from "./update.js";
@@ -4,9 +4,9 @@
4
4
  * Generates and displays interview questions in the terminal.
5
5
  *
6
6
  * Usage:
7
- * npx pathway interview <discipline> <grade> # Interview for trackless job
8
- * npx pathway interview <discipline> <grade> --track=<track> # Interview with track
9
- * npx pathway interview <discipline> <grade> --track=<track> --type=short
7
+ * npx fit-pathway interview <discipline> <grade> # All interview types
8
+ * npx fit-pathway interview <discipline> <grade> --track=<track> # With track
9
+ * npx fit-pathway interview <discipline> <grade> --track=<track> --type=mission # Single type
10
10
  */
11
11
 
12
12
  import { createCompositeCommand } from "./command-factory.js";
@@ -16,8 +16,10 @@ import {
16
16
  } from "../formatters/interview/shared.js";
17
17
  import { interviewToMarkdown } from "../formatters/interview/markdown.js";
18
18
 
19
+ const VALID_TYPES = Object.keys(INTERVIEW_TYPES);
20
+
19
21
  /**
20
- * Format interview output
22
+ * Format a single interview type as markdown
21
23
  * @param {Object} view - Presenter view
22
24
  * @param {Object} options - Options including framework
23
25
  */
@@ -25,15 +27,31 @@ function formatInterview(view, options) {
25
27
  console.log(interviewToMarkdown(view, { framework: options.framework }));
26
28
  }
27
29
 
30
+ /**
31
+ * Format all interview types as markdown with separators
32
+ * @param {Array<Object>} views - Array of presenter views
33
+ * @param {Object} options - Options including framework
34
+ */
35
+ function formatAllInterviews(views, options) {
36
+ for (let i = 0; i < views.length; i++) {
37
+ if (i > 0) {
38
+ console.log("\n" + "─".repeat(80) + "\n");
39
+ }
40
+ console.log(
41
+ interviewToMarkdown(views[i], { framework: options.framework }),
42
+ );
43
+ }
44
+ }
45
+
28
46
  export const runInterviewCommand = createCompositeCommand({
29
47
  commandName: "interview",
30
48
  requiredArgs: ["discipline_id", "grade_id"],
31
49
  findEntities: (data, args, options) => {
32
- const interviewType = options.type || "full";
50
+ const interviewType = options.type === "full" ? null : options.type;
33
51
 
34
- if (!INTERVIEW_TYPES[interviewType]) {
52
+ if (interviewType && !INTERVIEW_TYPES[interviewType]) {
35
53
  console.error(`Unknown interview type: ${interviewType}`);
36
- console.error("Available types: full, short, behaviour");
54
+ console.error(`Available types: ${VALID_TYPES.join(", ")}`);
37
55
  process.exit(1);
38
56
  }
39
57
 
@@ -58,18 +76,38 @@ export const runInterviewCommand = createCompositeCommand({
58
76
  }
59
77
  return null;
60
78
  },
61
- presenter: (entities, data, _options) =>
62
- prepareInterviewDetail({
79
+ presenter: (entities, data, _options) => {
80
+ const params = {
63
81
  discipline: entities.discipline,
64
82
  grade: entities.grade,
65
83
  track: entities.track,
66
84
  skills: data.skills,
67
85
  behaviours: data.behaviours,
68
86
  questions: data.questions,
69
- interviewType: entities.interviewType,
70
- }),
71
- formatter: (view, options, data) =>
72
- formatInterview(view, { ...options, framework: data.framework }),
87
+ };
88
+
89
+ // Single type: return one view
90
+ if (entities.interviewType) {
91
+ return prepareInterviewDetail({
92
+ ...params,
93
+ interviewType: entities.interviewType,
94
+ });
95
+ }
96
+
97
+ // All types: return array of views
98
+ return VALID_TYPES.map((type) =>
99
+ prepareInterviewDetail({ ...params, interviewType: type }),
100
+ ).filter(Boolean);
101
+ },
102
+ formatter: (view, options, data) => {
103
+ const opts = { ...options, framework: data.framework };
104
+
105
+ if (Array.isArray(view)) {
106
+ formatAllInterviews(view, opts);
107
+ } else {
108
+ formatInterview(view, opts);
109
+ }
110
+ },
73
111
  usageExample:
74
- "npx pathway interview software_engineering L4 --track=platform --type=short",
112
+ "npx fit-pathway interview software_engineering J090 --track=platform --type=mission",
75
113
  });
@@ -132,6 +132,7 @@ export async function runJobCommand({ data, args, options, dataDir }) {
132
132
  behaviours: data.behaviours,
133
133
  drivers: data.drivers,
134
134
  capabilities: data.capabilities,
135
+ stages: data.stages,
135
136
  });
136
137
 
137
138
  if (!view) {
@@ -56,12 +56,15 @@ function showQuestionsSummary(data) {
56
56
  "practitioner",
57
57
  "expert",
58
58
  ];
59
+ const roleTypes = ["professionalQuestions", "managementQuestions"];
59
60
  const skillRows = skillLevels.map((level) => {
60
61
  let count = 0;
61
62
  for (const skill of skills) {
62
- const sq = questions.skills?.[skill.id];
63
- if (sq?.[level]) {
64
- count += sq[level].length;
63
+ const sq = questions.skillLevels?.[skill.id];
64
+ if (sq) {
65
+ for (const roleType of roleTypes) {
66
+ count += (sq[roleType]?.[level] || []).length;
67
+ }
65
68
  }
66
69
  }
67
70
  return [level, count];
@@ -81,9 +84,11 @@ function showQuestionsSummary(data) {
81
84
  const behaviourRows = maturities.map((maturity) => {
82
85
  let count = 0;
83
86
  for (const behaviour of behaviours) {
84
- const bq = questions.behaviours?.[behaviour.id];
85
- if (bq?.[maturity]) {
86
- count += bq[maturity].length;
87
+ const bq = questions.behaviourMaturities?.[behaviour.id];
88
+ if (bq) {
89
+ for (const roleType of roleTypes) {
90
+ count += (bq[roleType]?.[maturity] || []).length;
91
+ }
87
92
  }
88
93
  }
89
94
  return [maturity.replace(/_/g, " "), count];
@@ -138,10 +143,8 @@ export async function runQuestionsCommand({
138
143
  behaviours: data.behaviours,
139
144
  filter,
140
145
  });
141
- for (const section of view.sections) {
142
- for (const q of section.questions) {
143
- console.log(q.id);
144
- }
146
+ for (const q of view.questions) {
147
+ console.log(q.id);
145
148
  }
146
149
  return;
147
150
  }
@@ -60,19 +60,19 @@ function formatDetail(viewAndContext, _framework) {
60
60
  console.log(formatHeader(`\n${emoji} ${view.name}\n`));
61
61
  console.log(`${view.description}\n`);
62
62
 
63
- // Entry criteria
64
- if (view.entryCriteria.length > 0) {
65
- console.log(formatSubheader("Entry Criteria\n"));
66
- for (const item of view.entryCriteria) {
63
+ // Read checklist
64
+ if (view.readChecklist.length > 0) {
65
+ console.log(formatSubheader("Read-Then-Do Checklist\n"));
66
+ for (const item of view.readChecklist) {
67
67
  console.log(formatBullet(item, 1));
68
68
  }
69
69
  console.log();
70
70
  }
71
71
 
72
- // Exit criteria
73
- if (view.exitCriteria.length > 0) {
74
- console.log(formatSubheader("Exit Criteria\n"));
75
- for (const item of view.exitCriteria) {
72
+ // Confirm checklist
73
+ if (view.confirmChecklist.length > 0) {
74
+ console.log(formatSubheader("Do-Then-Confirm Checklist\n"));
75
+ for (const item of view.confirmChecklist) {
76
76
  console.log(formatBullet(item, 1));
77
77
  }
78
78
  console.log();
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Update Command
3
+ *
4
+ * Re-downloads the distribution bundle from the published site URL
5
+ * and updates the local ~/.fit/pathway/ installation.
6
+ */
7
+
8
+ import { cp, mkdir, rm, readFile, writeFile, access } from "fs/promises";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
11
+ import { execFileSync, execSync } from "child_process";
12
+ import { loadFrameworkConfig } from "@forwardimpact/schema/loader";
13
+
14
+ const INSTALL_DIR = join(homedir(), ".fit", "pathway");
15
+
16
+ /**
17
+ * Run the update command.
18
+ * Reads siteUrl from the installed framework.yaml, re-downloads the bundle,
19
+ * extracts data, and runs npm install to update dependencies.
20
+ *
21
+ * @param {Object} params - Command parameters
22
+ * @param {string} params.dataDir - Path to data directory (may be the installed one)
23
+ * @param {Object} params.options - Command options
24
+ */
25
+ export async function runUpdateCommand({ dataDir: _dataDir, options }) {
26
+ const installDataDir = join(INSTALL_DIR, "data");
27
+
28
+ // Verify we have a home-directory installation
29
+ try {
30
+ await access(installDataDir);
31
+ } catch {
32
+ console.error("Error: No local installation found at ~/.fit/pathway/");
33
+ console.error(
34
+ "Install first using the install.sh script from your organization's pathway site.",
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ // Load framework config to get siteUrl
40
+ const framework = await loadFrameworkConfig(installDataDir);
41
+ const siteUrl = options.url || framework.distribution?.siteUrl;
42
+
43
+ if (!siteUrl) {
44
+ console.error(
45
+ "Error: No siteUrl found in ~/.fit/pathway/data/framework.yaml (distribution.siteUrl)",
46
+ );
47
+ console.error("Provide one with --url=<URL> or add it to framework.yaml.");
48
+ process.exit(1);
49
+ }
50
+
51
+ const baseUrl = siteUrl.replace(/\/$/, "");
52
+ const bundleName = "bundle.tar.gz";
53
+
54
+ console.log(`\n🔄 Updating from ${baseUrl}...\n`);
55
+
56
+ // 1. Download bundle to temp location
57
+ const tmpDir = join(INSTALL_DIR, "_update_tmp");
58
+ await mkdir(tmpDir, { recursive: true });
59
+
60
+ const tmpBundle = join(tmpDir, bundleName);
61
+
62
+ try {
63
+ console.log(" Downloading bundle...");
64
+ execFileSync("curl", [
65
+ "-fsSL",
66
+ `${baseUrl}/${bundleName}`,
67
+ "-o",
68
+ tmpBundle,
69
+ ]);
70
+ console.log(" ✓ Downloaded");
71
+
72
+ // 2. Extract bundle
73
+ console.log(" Extracting...");
74
+ const extractDir = join(tmpDir, "extracted");
75
+ await mkdir(extractDir, { recursive: true });
76
+ execFileSync("tar", [
77
+ "-xzf",
78
+ tmpBundle,
79
+ "-C",
80
+ extractDir,
81
+ "--strip-components=1",
82
+ ]);
83
+ console.log(" ✓ Extracted");
84
+
85
+ // 3. Compare versions
86
+ const newPkgPath = join(extractDir, "package.json");
87
+ const oldPkgPath = join(INSTALL_DIR, "package.json");
88
+ const newPkg = JSON.parse(await readFile(newPkgPath, "utf8"));
89
+ let oldPkg;
90
+ try {
91
+ oldPkg = JSON.parse(await readFile(oldPkgPath, "utf8"));
92
+ } catch {
93
+ oldPkg = { version: "unknown", dependencies: {} };
94
+ }
95
+
96
+ const oldVersion =
97
+ oldPkg.dependencies?.["@forwardimpact/pathway"] || "unknown";
98
+ const newVersion =
99
+ newPkg.dependencies?.["@forwardimpact/pathway"] || "unknown";
100
+
101
+ // 4. Replace data
102
+ console.log(" Updating data files...");
103
+ await rm(installDataDir, { recursive: true });
104
+ await cp(join(extractDir, "data"), installDataDir, { recursive: true });
105
+ console.log(" ✓ Data updated");
106
+
107
+ // 5. Update package.json if version changed
108
+ if (oldVersion !== newVersion) {
109
+ console.log(` Updating pathway ${oldVersion} → ${newVersion}...`);
110
+ await writeFile(oldPkgPath, JSON.stringify(newPkg, null, 2) + "\n");
111
+ console.log(" ✓ package.json updated");
112
+ }
113
+
114
+ // 6. Run npm install
115
+ console.log(" Installing dependencies...");
116
+ execSync("npm install --production --ignore-scripts --no-audit --no-fund", {
117
+ cwd: INSTALL_DIR,
118
+ stdio: "ignore",
119
+ });
120
+ console.log(" ✓ Dependencies installed");
121
+
122
+ // 7. Report
123
+ console.log(`
124
+ ✅ Update complete!
125
+
126
+ Pathway: ${oldVersion === newVersion ? newVersion + " (unchanged)" : oldVersion + " → " + newVersion}
127
+ Data: updated from ${baseUrl}
128
+ `);
129
+ } finally {
130
+ // Clean up temp directory
131
+ await rm(tmpDir, { recursive: true, force: true });
132
+ }
133
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Command Prompt Component
3
+ *
4
+ * Reusable terminal-style command display with copy button.
5
+ * Shows a `$` prompt, monospace command text, and a copy-to-clipboard button.
6
+ */
7
+
8
+ import { div, span, button } from "../lib/render.js";
9
+
10
+ const SVG_NS = "http://www.w3.org/2000/svg";
11
+
12
+ /**
13
+ * Create the copy icon SVG (two overlapping rectangles)
14
+ * @returns {SVGSVGElement}
15
+ */
16
+ function createCopyIcon() {
17
+ const svg = document.createElementNS(SVG_NS, "svg");
18
+ svg.setAttribute("viewBox", "0 0 24 24");
19
+
20
+ const rect = document.createElementNS(SVG_NS, "rect");
21
+ rect.setAttribute("x", "9");
22
+ rect.setAttribute("y", "9");
23
+ rect.setAttribute("width", "13");
24
+ rect.setAttribute("height", "13");
25
+ rect.setAttribute("rx", "2");
26
+
27
+ const path = document.createElementNS(SVG_NS, "path");
28
+ path.setAttribute(
29
+ "d",
30
+ "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1",
31
+ );
32
+
33
+ svg.appendChild(rect);
34
+ svg.appendChild(path);
35
+ return svg;
36
+ }
37
+
38
+ /**
39
+ * Copy text to clipboard with fallback for older browsers
40
+ * @param {string} text
41
+ * @param {HTMLButtonElement} btn
42
+ */
43
+ async function copyToClipboard(text, btn) {
44
+ try {
45
+ await navigator.clipboard.writeText(text);
46
+ } catch {
47
+ const textarea = document.createElement("textarea");
48
+ textarea.value = text;
49
+ textarea.style.position = "fixed";
50
+ textarea.style.opacity = "0";
51
+ document.body.appendChild(textarea);
52
+ textarea.select();
53
+ document.execCommand("copy");
54
+ document.body.removeChild(textarea);
55
+ }
56
+
57
+ btn.classList.add("copied");
58
+ btn.setAttribute("aria-label", "Copied!");
59
+ setTimeout(() => {
60
+ btn.classList.remove("copied");
61
+ btn.setAttribute("aria-label", "Copy command");
62
+ }, 2000);
63
+ }
64
+
65
+ /**
66
+ * Create a command prompt element with copy button
67
+ * @param {string} command - The command text to display
68
+ * @returns {HTMLElement}
69
+ */
70
+ export function createCommandPrompt(command) {
71
+ const copyBtn = button({
72
+ className: "command-prompt__copy",
73
+ "aria-label": "Copy command",
74
+ type: "button",
75
+ });
76
+ copyBtn.appendChild(createCopyIcon());
77
+ copyBtn.addEventListener("click", () => copyToClipboard(command, copyBtn));
78
+
79
+ return div(
80
+ { className: "command-prompt" },
81
+ span({ className: "command-prompt__prompt" }, "$"),
82
+ span({ className: "command-prompt__text" }, command),
83
+ copyBtn,
84
+ );
85
+ }