timeback 0.1.6 → 0.1.7

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 (3) hide show
  1. package/README.md +70 -1
  2. package/dist/cli.js +1908 -1526
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -31971,14 +31971,20 @@ var TimebackConfig = exports_external2.object({
31971
31971
  message: "Duplicate courseCode found; each must be unique",
31972
31972
  path: ["courses"]
31973
31973
  }).refine((config22) => {
31974
- return config22.courses.every((c) => c.sensor !== undefined || config22.sensor !== undefined);
31975
- }, {
31976
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
31977
- path: ["courses"]
31978
- }).refine((config22) => {
31979
- return config22.courses.every((c) => c.launchUrl !== undefined || config22.launchUrl !== undefined);
31974
+ return config22.courses.every((c) => {
31975
+ if (c.sensor !== undefined || config22.sensor !== undefined) {
31976
+ return true;
31977
+ }
31978
+ const launchUrls = [
31979
+ c.launchUrl,
31980
+ config22.launchUrl,
31981
+ c.overrides?.staging?.launchUrl,
31982
+ c.overrides?.production?.launchUrl
31983
+ ].filter(Boolean);
31984
+ return launchUrls.length > 0;
31985
+ });
31980
31986
  }, {
31981
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
31987
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
31982
31988
  path: ["courses"]
31983
31989
  });
31984
31990
  var EdubridgeDateString = exports_external2.union([IsoDateString, IsoDateTimeString]);
@@ -48374,14 +48380,20 @@ var TimebackConfig2 = exports_external22.object({
48374
48380
  message: "Duplicate courseCode found; each must be unique",
48375
48381
  path: ["courses"]
48376
48382
  }).refine((config222) => {
48377
- return config222.courses.every((c) => c.sensor !== undefined || config222.sensor !== undefined);
48378
- }, {
48379
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
48380
- path: ["courses"]
48381
- }).refine((config222) => {
48382
- return config222.courses.every((c) => c.launchUrl !== undefined || config222.launchUrl !== undefined);
48383
+ return config222.courses.every((c) => {
48384
+ if (c.sensor !== undefined || config222.sensor !== undefined) {
48385
+ return true;
48386
+ }
48387
+ const launchUrls = [
48388
+ c.launchUrl,
48389
+ config222.launchUrl,
48390
+ c.overrides?.staging?.launchUrl,
48391
+ c.overrides?.production?.launchUrl
48392
+ ].filter(Boolean);
48393
+ return launchUrls.length > 0;
48394
+ });
48383
48395
  }, {
48384
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
48396
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
48385
48397
  path: ["courses"]
48386
48398
  });
48387
48399
  var EdubridgeDateString2 = exports_external22.union([IsoDateString2, IsoDateTimeString2]);
@@ -65812,14 +65824,20 @@ var TimebackConfig3 = exports_external3.object({
65812
65824
  message: "Duplicate courseCode found; each must be unique",
65813
65825
  path: ["courses"]
65814
65826
  }).refine((config222) => {
65815
- return config222.courses.every((c) => c.sensor !== undefined || config222.sensor !== undefined);
65816
- }, {
65817
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
65818
- path: ["courses"]
65819
- }).refine((config222) => {
65820
- return config222.courses.every((c) => c.launchUrl !== undefined || config222.launchUrl !== undefined);
65827
+ return config222.courses.every((c) => {
65828
+ if (c.sensor !== undefined || config222.sensor !== undefined) {
65829
+ return true;
65830
+ }
65831
+ const launchUrls = [
65832
+ c.launchUrl,
65833
+ config222.launchUrl,
65834
+ c.overrides?.staging?.launchUrl,
65835
+ c.overrides?.production?.launchUrl
65836
+ ].filter(Boolean);
65837
+ return launchUrls.length > 0;
65838
+ });
65821
65839
  }, {
65822
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
65840
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
65823
65841
  path: ["courses"]
65824
65842
  });
65825
65843
  var EdubridgeDateString3 = exports_external3.union([IsoDateString3, IsoDateTimeString3]);
@@ -83444,14 +83462,20 @@ var TimebackConfig4 = exports_external4.object({
83444
83462
  message: "Duplicate courseCode found; each must be unique",
83445
83463
  path: ["courses"]
83446
83464
  }).refine((config222) => {
83447
- return config222.courses.every((c) => c.sensor !== undefined || config222.sensor !== undefined);
83448
- }, {
83449
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
83450
- path: ["courses"]
83451
- }).refine((config222) => {
83452
- return config222.courses.every((c) => c.launchUrl !== undefined || config222.launchUrl !== undefined);
83465
+ return config222.courses.every((c) => {
83466
+ if (c.sensor !== undefined || config222.sensor !== undefined) {
83467
+ return true;
83468
+ }
83469
+ const launchUrls = [
83470
+ c.launchUrl,
83471
+ config222.launchUrl,
83472
+ c.overrides?.staging?.launchUrl,
83473
+ c.overrides?.production?.launchUrl
83474
+ ].filter(Boolean);
83475
+ return launchUrls.length > 0;
83476
+ });
83453
83477
  }, {
83454
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
83478
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
83455
83479
  path: ["courses"]
83456
83480
  });
83457
83481
  var EdubridgeDateString4 = exports_external4.union([IsoDateString4, IsoDateTimeString4]);
@@ -100106,14 +100130,20 @@ var TimebackConfig5 = exports_external5.object({
100106
100130
  message: "Duplicate courseCode found; each must be unique",
100107
100131
  path: ["courses"]
100108
100132
  }).refine((config222) => {
100109
- return config222.courses.every((c) => c.sensor !== undefined || config222.sensor !== undefined);
100110
- }, {
100111
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
100112
- path: ["courses"]
100113
- }).refine((config222) => {
100114
- return config222.courses.every((c) => c.launchUrl !== undefined || config222.launchUrl !== undefined);
100133
+ return config222.courses.every((c) => {
100134
+ if (c.sensor !== undefined || config222.sensor !== undefined) {
100135
+ return true;
100136
+ }
100137
+ const launchUrls = [
100138
+ c.launchUrl,
100139
+ config222.launchUrl,
100140
+ c.overrides?.staging?.launchUrl,
100141
+ c.overrides?.production?.launchUrl
100142
+ ].filter(Boolean);
100143
+ return launchUrls.length > 0;
100144
+ });
100115
100145
  }, {
100116
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
100146
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
100117
100147
  path: ["courses"]
100118
100148
  });
100119
100149
  var EdubridgeDateString5 = exports_external5.union([IsoDateString5, IsoDateTimeString5]);
@@ -102332,14 +102362,20 @@ var TimebackConfig6 = exports_external.object({
102332
102362
  message: "Duplicate courseCode found; each must be unique",
102333
102363
  path: ["courses"]
102334
102364
  }).refine((config6) => {
102335
- return config6.courses.every((c) => c.sensor !== undefined || config6.sensor !== undefined);
102336
- }, {
102337
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
102338
- path: ["courses"]
102339
- }).refine((config6) => {
102340
- return config6.courses.every((c) => c.launchUrl !== undefined || config6.launchUrl !== undefined);
102365
+ return config6.courses.every((c) => {
102366
+ if (c.sensor !== undefined || config6.sensor !== undefined) {
102367
+ return true;
102368
+ }
102369
+ const launchUrls = [
102370
+ c.launchUrl,
102371
+ config6.launchUrl,
102372
+ c.overrides?.staging?.launchUrl,
102373
+ c.overrides?.production?.launchUrl
102374
+ ].filter(Boolean);
102375
+ return launchUrls.length > 0;
102376
+ });
102341
102377
  }, {
102342
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
102378
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
102343
102379
  path: ["courses"]
102344
102380
  });
102345
102381
  // ../types/src/zod/edubridge.ts
@@ -103453,6 +103489,46 @@ function toCourseConfig(course, env2) {
103453
103489
  }
103454
103490
  return config7;
103455
103491
  }
103492
+ async function inferLaunchUrl(client, courseId) {
103493
+ try {
103494
+ const scopedCourse = client.oneroster.courses(courseId);
103495
+ const components = await scopedCourse.components({ where: { status: "active" } });
103496
+ const componentResourceIds = [];
103497
+ for (const component of components) {
103498
+ if (!component.sourcedId)
103499
+ continue;
103500
+ const crs = await client.oneroster.courses.componentResources({
103501
+ where: { "courseComponent.sourcedId": component.sourcedId, status: "active" }
103502
+ });
103503
+ for (const cr of crs) {
103504
+ if (cr.resource?.sourcedId) {
103505
+ componentResourceIds.push(cr.resource.sourcedId);
103506
+ }
103507
+ }
103508
+ }
103509
+ if (componentResourceIds.length === 0) {
103510
+ return;
103511
+ }
103512
+ const resourceIds = [...new Set(componentResourceIds)];
103513
+ const resources = await client.oneroster.resources.listAll({
103514
+ where: { sourcedId: { in: resourceIds } }
103515
+ });
103516
+ const launchUrls = new Set;
103517
+ for (const resource of resources) {
103518
+ const metadata = resource.metadata;
103519
+ const launchUrl = metadata?.launchUrl;
103520
+ if (typeof launchUrl === "string" && launchUrl.length > 0) {
103521
+ launchUrls.add(launchUrl);
103522
+ }
103523
+ }
103524
+ if (launchUrls.size === 1) {
103525
+ return [...launchUrls][0];
103526
+ }
103527
+ return;
103528
+ } catch {
103529
+ return;
103530
+ }
103531
+ }
103456
103532
  // ../internal/cli-infra/src/search/search.ts
103457
103533
  function searchCourses(client, query, max = 50) {
103458
103534
  return client.oneroster.courses.listAll({
@@ -103475,11 +103551,11 @@ async function promptSelectEnv(configuredEnvs) {
103475
103551
  }
103476
103552
  async function promptAppName() {
103477
103553
  const name = await he({
103478
- message: "App name",
103554
+ message: "Search for your app",
103479
103555
  placeholder: "My Timeback App",
103480
103556
  validate: (value) => {
103481
103557
  if (!value.trim())
103482
- return "App name is required";
103558
+ return "Please enter a search term";
103483
103559
  }
103484
103560
  });
103485
103561
  if (pD(name))
@@ -103584,6 +103660,40 @@ async function selectCourses(client, initialResults, filterFn) {
103584
103660
  results = filter(moreResults);
103585
103661
  }
103586
103662
  }
103663
+ async function enrichCoursesWithLaunchUrls(client, courses) {
103664
+ if (courses.length === 0)
103665
+ return courses;
103666
+ const s = Y2();
103667
+ let didStop = false;
103668
+ const stopOnce = (message) => {
103669
+ if (didStop)
103670
+ return;
103671
+ s.stop(message);
103672
+ didStop = true;
103673
+ };
103674
+ s.start("Fetching course details...");
103675
+ const enriched = [];
103676
+ try {
103677
+ for (const course of courses) {
103678
+ const courseId = course.ids?.staging ?? course.ids?.production;
103679
+ if (!courseId) {
103680
+ enriched.push(course);
103681
+ continue;
103682
+ }
103683
+ const launchUrl = await inferLaunchUrl(client, courseId);
103684
+ if (launchUrl && !course.launchUrl) {
103685
+ enriched.push({ ...course, launchUrl });
103686
+ } else {
103687
+ enriched.push(course);
103688
+ }
103689
+ }
103690
+ stopOnce("Course details loaded");
103691
+ return enriched;
103692
+ } catch (error57) {
103693
+ stopOnce("Failed to load course details");
103694
+ throw error57;
103695
+ }
103696
+ }
103587
103697
  async function searchAndSelectCourses(options) {
103588
103698
  const { client, environment, appName: existingAppName, excludeCourseIds = [] } = options;
103589
103699
  const filterExcluded = (courses) => {
@@ -103606,11 +103716,11 @@ async function searchAndSelectCourses(options) {
103606
103716
  return { success: false, cancelled: true };
103607
103717
  }
103608
103718
  const courseConfigs = courses.map((c) => toCourseConfig(c, environment));
103609
- M2.success(`Selected ${courseConfigs.length} course${courseConfigs.length === 1 ? "" : "s"}`);
103719
+ const enrichedConfigs = await enrichCoursesWithLaunchUrls(client, courseConfigs);
103610
103720
  return {
103611
103721
  success: true,
103612
103722
  appName,
103613
- courses: courseConfigs,
103723
+ courses: enrichedConfigs,
103614
103724
  environment
103615
103725
  };
103616
103726
  } catch (error57) {
@@ -103626,35 +103736,6 @@ function isValidUrl(url6) {
103626
103736
  return false;
103627
103737
  }
103628
103738
  }
103629
- function deriveSensorFromLaunchUrl(launchUrl) {
103630
- try {
103631
- const url6 = new URL(launchUrl);
103632
- return url6.origin;
103633
- } catch {
103634
- return;
103635
- }
103636
- }
103637
- async function promptSensor(options) {
103638
- const { defaultValue } = options ?? {};
103639
- const input = await he({
103640
- message: "Sensor URL (your app origin, used for activity tracking)",
103641
- placeholder: "https://myapp.example.com",
103642
- defaultValue,
103643
- validate: (value) => {
103644
- if (!value.trim()) {
103645
- return "Sensor URL is required for activity tracking";
103646
- }
103647
- if (!isValidUrl(value.trim())) {
103648
- return "Invalid URL format";
103649
- }
103650
- return;
103651
- }
103652
- });
103653
- if (isCancelled(input)) {
103654
- return;
103655
- }
103656
- return input.trim();
103657
- }
103658
103739
  async function promptLaunchUrl() {
103659
103740
  const input = await he({
103660
103741
  message: "Launch URL (where students go when clicking your app in dashboards)",
@@ -104645,30 +104726,43 @@ function run(cmd, args, cwd, silent = false) {
104645
104726
  child.on("error", () => resolvePromise(1));
104646
104727
  });
104647
104728
  }
104729
+ function getPrettierBin(cwd) {
104730
+ return resolve3(cwd, "node_modules", ".bin", process.platform === "win32" ? "prettier.cmd" : "prettier");
104731
+ }
104732
+ function runPrettierUsingRunner(cwd, args) {
104733
+ const { packageManager } = detectPackageManager(cwd);
104734
+ const dlx = getDlxCommand(packageManager, "prettier");
104735
+ return run(dlx.cmd, [...dlx.args, ...args], cwd, true);
104736
+ }
104648
104737
  async function formatWithPrettier(options) {
104649
- const { cwd, filePath, logger, silent = false } = options;
104650
- const prettierBin = resolve3(cwd, "node_modules", ".bin", process.platform === "win32" ? "prettier.cmd" : "prettier");
104738
+ const { cwd, filePath, silent = false } = options;
104739
+ const prettierBin = getPrettierBin(cwd);
104651
104740
  const args = ["--write", filePath];
104652
- if (existsSync3(prettierBin)) {
104653
- if (!silent)
104654
- logger.info(`Formatting ${filePath} with prettier...`);
104655
- const code2 = await run(prettierBin, args, cwd, silent);
104656
- if (code2 === 0)
104741
+ const s = silent ? null : Y2();
104742
+ s?.start("Formatting config file...");
104743
+ try {
104744
+ let code;
104745
+ if (existsSync3(prettierBin)) {
104746
+ code = await run(prettierBin, args, cwd, true);
104747
+ } else {
104748
+ code = await runPrettierUsingRunner(cwd, args);
104749
+ }
104750
+ if (code === 0) {
104751
+ s?.stop("Formatted config file");
104657
104752
  return true;
104658
- if (!silent)
104659
- logger.warn(`Prettier failed (exit ${code2}).`);
104753
+ }
104754
+ s?.stop();
104755
+ if (!silent) {
104756
+ M2.warn("Failed to format config file");
104757
+ }
104758
+ return false;
104759
+ } catch {
104760
+ s?.stop();
104761
+ if (!silent) {
104762
+ M2.warn("Failed to format config file");
104763
+ }
104660
104764
  return false;
104661
104765
  }
104662
- const { packageManager } = detectPackageManager(cwd);
104663
- const dlx = getDlxCommand(packageManager, "prettier");
104664
- if (!silent)
104665
- logger.info(`Formatting ${filePath} with ${dlx.cmd}...`);
104666
- const code = await run(dlx.cmd, [...dlx.args, ...args], cwd, silent);
104667
- if (code === 0)
104668
- return true;
104669
- if (!silent)
104670
- logger.warn(`Prettier failed (exit ${code}).`);
104671
- return false;
104672
104766
  }
104673
104767
  // src/lib/config/generate.ts
104674
104768
  var SCHEMA_URL = "https://timeback.dev/schema.json";
@@ -104826,6 +104920,32 @@ function formatCourse(course) {
104826
104920
  }
104827
104921
  return orderKeys(result, COURSE_KEY_ORDER);
104828
104922
  }
104923
+ function normalizeLaunchUrls(config7) {
104924
+ const allCoursesHaveLaunchUrl = config7.courses.every((c) => c.launchUrl !== undefined);
104925
+ if (!allCoursesHaveLaunchUrl) {
104926
+ return config7;
104927
+ }
104928
+ const courseLaunchUrls = config7.courses.map((c) => c.launchUrl);
104929
+ const uniqueUrls = new Set(courseLaunchUrls);
104930
+ if (uniqueUrls.size !== 1) {
104931
+ return config7;
104932
+ }
104933
+ const sharedUrl = courseLaunchUrls[0];
104934
+ if (config7.launchUrl && config7.launchUrl !== sharedUrl) {
104935
+ return config7;
104936
+ }
104937
+ return {
104938
+ ...config7,
104939
+ launchUrl: sharedUrl,
104940
+ courses: config7.courses.map((course) => {
104941
+ if (course.launchUrl === sharedUrl) {
104942
+ const { launchUrl: _3, ...rest } = course;
104943
+ return rest;
104944
+ }
104945
+ return course;
104946
+ })
104947
+ };
104948
+ }
104829
104949
  function generateConfigContent(config7) {
104830
104950
  const output2 = {
104831
104951
  $schema: SCHEMA_URL,
@@ -104848,9 +104968,13 @@ function generateConfigContent(config7) {
104848
104968
  }
104849
104969
  // src/lib/config/loader.ts
104850
104970
  import { writeFile as writeFile2 } from "node:fs/promises";
104851
- async function saveConfig(configPath, config7) {
104971
+ async function saveConfig(configPath, config7, options = {}) {
104972
+ const { format = false, cwd = process.cwd(), silent = true } = options;
104852
104973
  const content = generateConfigContent(config7);
104853
104974
  await writeFile2(configPath, content, "utf-8");
104975
+ if (format) {
104976
+ await formatWithPrettier({ cwd, filePath: configPath, silent });
104977
+ }
104854
104978
  }
104855
104979
  // src/lib/compare.ts
104856
104980
  function compareValues(local, remote, path, changes, options = {}) {
@@ -104921,32 +105045,84 @@ function logErrors(errors3) {
104921
105045
  }
104922
105046
  }
104923
105047
 
104924
- // src/lib/courses.ts
104925
- async function fetchRemoteCourseData(synced) {
104926
- if (synced.length === 0)
104927
- return;
104928
- const first = synced[0];
104929
- const s = Y2();
104930
- s.start("Fetching course data...");
104931
- const authOk = await initAuth({ env: first.env });
104932
- if (!authOk) {
104933
- s.stop("Could not authenticate");
105048
+ // src/version.ts
105049
+ var cliVersion = "0.1.7";
105050
+
105051
+ // src/lib/metadata.ts
105052
+ function deepMerge(base, override) {
105053
+ if (!base && !override)
104934
105054
  return;
105055
+ if (!base)
105056
+ return override;
105057
+ if (!override)
105058
+ return base;
105059
+ const result = { ...base };
105060
+ for (const key of Object.keys(override)) {
105061
+ const baseVal = base[key];
105062
+ const overrideVal = override[key];
105063
+ if (overrideVal !== undefined && typeof overrideVal === "object" && overrideVal !== null && !Array.isArray(overrideVal) && typeof baseVal === "object" && baseVal !== null && !Array.isArray(baseVal)) {
105064
+ result[key] = deepMerge(baseVal, overrideVal);
105065
+ } else if (overrideVal !== undefined) {
105066
+ result[key] = overrideVal;
105067
+ }
104935
105068
  }
104936
- try {
104937
- const client = getAuthContext(first.env).timeback;
104938
- const remote = await client.oneroster.courses.get(first.courseId);
104939
- s.stop("Course data loaded");
104940
- return {
104941
- courseCode: remote.courseCode ?? undefined,
104942
- level: remote.level ?? undefined,
104943
- metadata: remote.metadata ?? undefined
104944
- };
104945
- } catch {
104946
- s.stop("Could not fetch course data");
105069
+ return result;
105070
+ }
105071
+ function buildConfigMetadata(course) {
105072
+ if (!course.metadata)
104947
105073
  return;
104948
- }
105074
+ const m2 = course.metadata;
105075
+ const metadata = {};
105076
+ if (m2.courseType)
105077
+ metadata.courseType = m2.courseType;
105078
+ if (m2.isSupplemental !== undefined)
105079
+ metadata.isSupplemental = m2.isSupplemental;
105080
+ if (m2.isCustom !== undefined)
105081
+ metadata.isCustom = m2.isCustom;
105082
+ if (m2.publishStatus)
105083
+ metadata.publishStatus = m2.publishStatus;
105084
+ if (m2.contactEmail)
105085
+ metadata.contactEmail = m2.contactEmail;
105086
+ if (m2.primaryApp)
105087
+ metadata.primaryApp = m2.primaryApp;
105088
+ if (m2.goals)
105089
+ metadata.goals = m2.goals;
105090
+ if (m2.metrics)
105091
+ metadata.metrics = m2.metrics;
105092
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
104949
105093
  }
105094
+ function buildUpsertMetadata(existing, course) {
105095
+ const base = existing ?? {};
105096
+ const configMetadata = buildConfigMetadata(course);
105097
+ const result = { ...base };
105098
+ if (configMetadata) {
105099
+ if (configMetadata.courseType !== undefined)
105100
+ result.courseType = configMetadata.courseType;
105101
+ if (configMetadata.isSupplemental !== undefined)
105102
+ result.isSupplemental = configMetadata.isSupplemental;
105103
+ if (configMetadata.isCustom !== undefined)
105104
+ result.isCustom = configMetadata.isCustom;
105105
+ if (configMetadata.publishStatus !== undefined)
105106
+ result.publishStatus = configMetadata.publishStatus;
105107
+ if (configMetadata.contactEmail !== undefined)
105108
+ result.contactEmail = configMetadata.contactEmail;
105109
+ if (configMetadata.primaryApp !== undefined)
105110
+ result.primaryApp = configMetadata.primaryApp;
105111
+ if (configMetadata.goals || base.goals) {
105112
+ result.goals = deepMerge(base.goals, configMetadata.goals);
105113
+ }
105114
+ if (configMetadata.metrics || base.metrics) {
105115
+ result.metrics = deepMerge(base.metrics, configMetadata.metrics);
105116
+ }
105117
+ }
105118
+ result.managedBy = `timeback@${cliVersion}`;
105119
+ return result;
105120
+ }
105121
+ function buildCreateMetadata(course) {
105122
+ return buildUpsertMetadata(undefined, course);
105123
+ }
105124
+
105125
+ // src/lib/courses.ts
104950
105126
  function mergeMetadata(base, override) {
104951
105127
  if (!base && !override)
104952
105128
  return;
@@ -104983,29 +105159,6 @@ function resolveCourseForEnv(course, defaults, env2) {
104983
105159
  metadata: mergeMetadata(merged.metadata, envOverrides.metadata)
104984
105160
  };
104985
105161
  }
104986
- function buildMetadata(course) {
104987
- if (!course.metadata)
104988
- return;
104989
- const m2 = course.metadata;
104990
- const metadata = {};
104991
- if (m2.courseType)
104992
- metadata.courseType = m2.courseType;
104993
- if (m2.isSupplemental !== undefined)
104994
- metadata.isSupplemental = m2.isSupplemental;
104995
- if (m2.isCustom !== undefined)
104996
- metadata.isCustom = m2.isCustom;
104997
- if (m2.publishStatus)
104998
- metadata.publishStatus = m2.publishStatus;
104999
- if (m2.contactEmail)
105000
- metadata.contactEmail = m2.contactEmail;
105001
- if (m2.primaryApp)
105002
- metadata.primaryApp = m2.primaryApp;
105003
- if (m2.goals)
105004
- metadata.goals = m2.goals;
105005
- if (m2.metrics)
105006
- metadata.metrics = m2.metrics;
105007
- return Object.keys(metadata).length > 0 ? metadata : undefined;
105008
- }
105009
105162
  async function updateCourse(options) {
105010
105163
  const { env: env2, courseId, title, course, defaults } = options;
105011
105164
  const authOk = await initAuth({ env: env2 });
@@ -105020,10 +105173,10 @@ async function updateCourse(options) {
105020
105173
  };
105021
105174
  }
105022
105175
  const resolved = resolveCourseForEnv(course, defaults, env2);
105023
- const metadata = buildMetadata(resolved);
105024
105176
  try {
105025
105177
  const client = getAuthContext(env2).timeback;
105026
105178
  const existing = await client.oneroster.courses.get(courseId);
105179
+ const metadata = buildUpsertMetadata(existing.metadata, resolved);
105027
105180
  await client.oneroster.courses.update(courseId, {
105028
105181
  title,
105029
105182
  subjects: [resolved.subject],
@@ -105032,7 +105185,7 @@ async function updateCourse(options) {
105032
105185
  org: existing.org,
105033
105186
  courseCode: resolved.courseCode,
105034
105187
  level: resolved.level,
105035
- metadata: metadata ?? existing.metadata
105188
+ metadata
105036
105189
  });
105037
105190
  return { success: true };
105038
105191
  } catch (error57) {
@@ -105052,37 +105205,41 @@ function deriveCourseStructureIds(courseId) {
105052
105205
  componentResource: `${courseId}-cr`
105053
105206
  };
105054
105207
  }
105055
- async function resourceExists(getter) {
105208
+ async function getIfExists(getter) {
105056
105209
  try {
105057
- await getter();
105058
- return true;
105210
+ return await getter();
105059
105211
  } catch (error57) {
105060
105212
  if (isApiError2(error57) && error57.statusCode === 404) {
105061
- return false;
105213
+ return null;
105062
105214
  }
105063
105215
  throw error57;
105064
105216
  }
105065
105217
  }
105066
105218
  async function ensureComponent(client, ids, title) {
105067
105219
  const componentTitle = `${title} - Component`;
105068
- const exists = await resourceExists(() => client.oneroster.courses.getComponent(ids.component));
105069
- if (exists) {
105070
- await client.oneroster.courses.updateComponent(ids.component, {
105071
- title: componentTitle,
105072
- status: "active"
105073
- });
105074
- } else {
105220
+ const existing = await getIfExists(() => client.oneroster.courses.getComponent(ids.component));
105221
+ if (!existing) {
105075
105222
  await client.oneroster.courses.createComponent({
105076
105223
  sourcedId: ids.component,
105077
105224
  title: componentTitle,
105078
105225
  course: { sourcedId: ids.course },
105079
105226
  status: "active"
105080
105227
  });
105228
+ return;
105081
105229
  }
105230
+ const needsUpdate = existing.title !== componentTitle || existing.status !== "active" || existing.course?.sourcedId !== ids.course;
105231
+ if (!needsUpdate)
105232
+ return;
105233
+ await client.oneroster.courses.updateComponent(ids.component, {
105234
+ ...existing,
105235
+ title: componentTitle,
105236
+ course: { sourcedId: ids.course },
105237
+ status: "active"
105238
+ });
105082
105239
  }
105083
105240
  async function ensureResource(client, ids, title, appName, launchUrl) {
105084
105241
  const resourceTitle = `${title} - Resource`;
105085
- const exists = await resourceExists(() => client.oneroster.resources.get(ids.resource));
105242
+ const existing = await getIfExists(() => client.oneroster.resources.get(ids.resource));
105086
105243
  const resourceData = {
105087
105244
  title: resourceTitle,
105088
105245
  vendorResourceId: ids.resource,
@@ -105097,24 +105254,23 @@ async function ensureResource(client, ids, title, appName, launchUrl) {
105097
105254
  toolProvider: appName
105098
105255
  }
105099
105256
  };
105100
- if (exists) {
105101
- await client.oneroster.resources.update(ids.resource, resourceData);
105102
- } else {
105257
+ if (!existing) {
105103
105258
  await client.oneroster.resources.create({
105104
105259
  sourcedId: ids.resource,
105105
105260
  ...resourceData
105106
105261
  });
105262
+ return;
105107
105263
  }
105264
+ const existingLaunchUrl = existing.metadata?.launchUrl;
105265
+ const needsUpdate = existing.title !== resourceTitle || String(existingLaunchUrl ?? "") !== launchUrl;
105266
+ if (!needsUpdate)
105267
+ return;
105268
+ await client.oneroster.resources.update(ids.resource, resourceData);
105108
105269
  }
105109
105270
  async function ensureComponentResource(client, ids, title) {
105110
105271
  const crTitle = `${title} - Link`;
105111
- const exists = await resourceExists(() => client.oneroster.courses.getComponentResource(ids.componentResource));
105112
- if (exists) {
105113
- await client.oneroster.courses.updateComponentResource(ids.componentResource, {
105114
- title: crTitle,
105115
- status: "active"
105116
- });
105117
- } else {
105272
+ const existing = await getIfExists(() => client.oneroster.courses.getComponentResource(ids.componentResource));
105273
+ if (!existing) {
105118
105274
  await client.oneroster.courses.createComponentResource({
105119
105275
  sourcedId: ids.componentResource,
105120
105276
  title: crTitle,
@@ -105122,7 +105278,19 @@ async function ensureComponentResource(client, ids, title) {
105122
105278
  resource: { sourcedId: ids.resource },
105123
105279
  status: "active"
105124
105280
  });
105281
+ return;
105125
105282
  }
105283
+ const needsUpdate = existing.title !== crTitle || existing.status !== "active" || existing.courseComponent?.sourcedId !== ids.component || existing.resource?.sourcedId !== ids.resource;
105284
+ if (!needsUpdate)
105285
+ return;
105286
+ await client.oneroster.courses.updateComponentResource(ids.componentResource, {
105287
+ ...existing,
105288
+ sourcedId: ids.componentResource,
105289
+ title: crTitle,
105290
+ courseComponent: { sourcedId: ids.component },
105291
+ resource: { sourcedId: ids.resource },
105292
+ status: "active"
105293
+ });
105126
105294
  }
105127
105295
  async function ensureCourseStructure(options) {
105128
105296
  const { env: env2, courseId, courseTitle, appName, launchUrl, onProgress } = options;
@@ -105155,43 +105323,6 @@ async function ensureCourseStructure(options) {
105155
105323
  };
105156
105324
  }
105157
105325
  }
105158
- // src/lib/diff.ts
105159
- function formatCourse2(course) {
105160
- return `${course.subject} - Grade ${course.grade}`;
105161
- }
105162
- function logAdditions(courses) {
105163
- M2.message("");
105164
- M2.message(bold("Changes:"));
105165
- const lines = courses.map((c) => ` ${green("+")} ${formatCourse2(c)}`);
105166
- M2.message(lines.join(`
105167
- `));
105168
- M2.message("");
105169
- }
105170
- function logRemovals(courses) {
105171
- M2.message("");
105172
- M2.message(bold("Changes:"));
105173
- const lines = courses.map((c) => ` ${red("-")} ${formatCourse2(c)}`);
105174
- M2.message(lines.join(`
105175
- `));
105176
- M2.message("");
105177
- }
105178
- function logEdit(original, edited) {
105179
- const changes = compareCourseFields(edited, original);
105180
- if (changes.length === 0) {
105181
- return;
105182
- }
105183
- M2.message(bold("Changes:"));
105184
- const maxFieldLen = Math.max(...changes.map((c) => c.field.length));
105185
- const maxOldLen = Math.max(...changes.map((c) => c.remote.length || 7));
105186
- const lines = changes.map((change) => {
105187
- const paddedField = change.field.padStart(maxFieldLen);
105188
- const paddedOld = (change.remote || "(empty)").padStart(maxOldLen);
105189
- const newVal = change.local || dim("(empty)");
105190
- return ` ${dim(`${paddedField}:`)} ${red(paddedOld)} ${dim("→")} ${green(newVal)}`;
105191
- });
105192
- M2.message(lines.join(`
105193
- `));
105194
- }
105195
105326
  // src/lib/environment/prompts.ts
105196
105327
  async function promptEnvironment() {
105197
105328
  const env2 = await ve({
@@ -105222,8 +105353,12 @@ async function resolveEnvironment(options = {}) {
105222
105353
  return null;
105223
105354
  return { env: selected, promptedUser: true };
105224
105355
  }
105356
+ // ../internal/utils/src/shared/format.ts
105357
+ function pluralize(count, singular, plural) {
105358
+ return count === 1 ? singular : plural ?? `${singular}s`;
105359
+ }
105225
105360
  // src/lib/org/select.ts
105226
- async function selectOrganization(client, email4) {
105361
+ async function selectOrganization(client, email4, courseCount) {
105227
105362
  const s = Y2();
105228
105363
  s.start("Fetching your organizations...");
105229
105364
  const user = await client.oneroster.users.first({ where: { email: email4 } });
@@ -105255,7 +105390,7 @@ async function selectOrganization(client, email4) {
105255
105390
  }
105256
105391
  s.stop("Organizations loaded");
105257
105392
  const orgChoice = await ve({
105258
- message: "Select an organization for the new courses:",
105393
+ message: `Select an organization for the new ${pluralize(courseCount ?? 2, "course")}:`,
105259
105394
  options: Array.from(orgMap.entries()).map(([id, name]) => ({
105260
105395
  value: id,
105261
105396
  label: name
@@ -105297,8 +105432,6 @@ async function searchAndSelectCourses2(options) {
105297
105432
  }
105298
105433
  }
105299
105434
  // src/lib/setup/setup.ts
105300
- import { writeFile as writeFile3 } from "node:fs/promises";
105301
- import { resolve as resolve4 } from "node:path";
105302
105435
  var CONFIG_FILENAME2 = "timeback.config.json";
105303
105436
  async function promptEnvironmentToSetup() {
105304
105437
  const env2 = await ve({
@@ -105327,67 +105460,6 @@ You need to configure your Timeback API credentials first.`, "Credentials Setup"
105327
105460
  Me(`Saved to ${dim(getCredentialsPath())}`, "Persisted credentials to disk");
105328
105461
  return true;
105329
105462
  }
105330
- async function ensureConfig(opts = {}) {
105331
- const { configPath: customPath, skipIntro = false, format = true } = opts;
105332
- if (!skipIntro) {
105333
- intro("Timeback");
105334
- }
105335
- const cwd = process.cwd();
105336
- const configPath = customPath ? resolve4(cwd, customPath) : resolve4(cwd, CONFIG_FILENAME2);
105337
- const configResult = await loadConfig({ configPath: customPath });
105338
- if (configResult.success) {
105339
- M2.info(`Found ${dim(CONFIG_FILENAME2)}`);
105340
- return {
105341
- success: true,
105342
- config: configResult.config,
105343
- configPath: configResult.configPath
105344
- };
105345
- }
105346
- Me(`No ${greenBright(CONFIG_FILENAME2)} found in this directory.
105347
- Let's set up your Timeback project.`, "First-time setup");
105348
- let configuredEnvs = await getConfiguredEnvironments();
105349
- if (configuredEnvs.length === 0) {
105350
- const ok = await setupCredentials();
105351
- if (!ok) {
105352
- return { success: false, cancelled: true };
105353
- }
105354
- configuredEnvs = await getConfiguredEnvironments();
105355
- if (configuredEnvs.length === 0) {
105356
- return { success: false, error: "Failed to configure credentials" };
105357
- }
105358
- }
105359
- const searchResult = await searchAndSelectCourses2({ configuredEnvs });
105360
- if (!searchResult.success) {
105361
- return {
105362
- success: false,
105363
- cancelled: searchResult.cancelled,
105364
- error: searchResult.error
105365
- };
105366
- }
105367
- const sensor = await promptSensor();
105368
- if (sensor === undefined) {
105369
- return { success: false, cancelled: true };
105370
- }
105371
- const config7 = {
105372
- name: searchResult.appName,
105373
- sensor,
105374
- courses: searchResult.courses
105375
- };
105376
- const content = generateConfigContent(config7);
105377
- await writeFile3(configPath, content, "utf-8");
105378
- M2.success(`Created ${dim(CONFIG_FILENAME2)}`);
105379
- if (format) {
105380
- await formatWithPrettier({ cwd, filePath: configPath, logger: M2 });
105381
- }
105382
- if (!skipIntro) {
105383
- outro.success();
105384
- }
105385
- return {
105386
- success: true,
105387
- config: config7,
105388
- configPath
105389
- };
105390
- }
105391
105463
  async function runImportFlow(opts = {}) {
105392
105464
  const { format = true } = opts;
105393
105465
  let configuredEnvs = await getConfiguredEnvironments();
@@ -105434,15 +105506,14 @@ async function runImportFlow(opts = {}) {
105434
105506
  config7.courses.push(course);
105435
105507
  }
105436
105508
  }
105437
- const content = generateConfigContent(config7);
105438
- await writeFile3(configPath, content, "utf-8");
105439
- M2.success(`Updated ${dim(CONFIG_FILENAME2)}`);
105440
- if (format) {
105441
- await formatWithPrettier({ cwd: process.cwd(), filePath: configPath, logger: M2 });
105442
- }
105509
+ const s = Y2();
105510
+ s.start(`Updating ${dim(CONFIG_FILENAME2)}...`);
105511
+ const normalizedConfig = normalizeLaunchUrls(config7);
105512
+ await saveConfig(configPath, normalizedConfig, { format });
105513
+ s.stop(`Updated ${dim(CONFIG_FILENAME2)}`);
105443
105514
  return {
105444
105515
  success: true,
105445
- config: config7,
105516
+ config: normalizedConfig,
105446
105517
  configPath
105447
105518
  };
105448
105519
  }
@@ -108681,115 +108752,11 @@ function registerCredentialsCommand(program2) {
108681
108752
  creds.action(() => showCredentialsMenu());
108682
108753
  }
108683
108754
 
108684
- // src/commands/sync/lib/courses.ts
108685
- async function applyDiff(options) {
108686
- const { config: config7, diff, createEnv, createOrgId, onProgress } = options;
108687
- const result = {
108688
- created: 0,
108689
- updated: 0,
108690
- skipped: diff.unchanged.length,
108691
- errors: [...diff.errors]
108692
- };
108693
- const updatedConfig = { ...config7, courses: [...config7.courses] };
108694
- for (const create of diff.creates) {
108695
- if (!createEnv || !createOrgId) {
108696
- result.errors.push({
108697
- title: create.title,
108698
- header: "Environment and organization required for new courses",
108699
- details: []
108700
- });
108701
- continue;
108702
- }
108703
- const authOk = await initAuth({ env: createEnv });
108704
- if (!authOk) {
108705
- result.errors.push({
108706
- title: create.title,
108707
- header: `Failed to authenticate for ${createEnv}`,
108708
- details: []
108709
- });
108710
- continue;
108711
- }
108712
- onProgress?.(`Creating ${create.title} on ${createEnv}...`);
108713
- const resolved = resolveCourseForEnv(create.local, config7.defaults, createEnv);
108714
- const metadata = buildMetadata(resolved);
108715
- try {
108716
- const client = getAuthContext(createEnv).timeback;
108717
- const response = await client.oneroster.courses.create({
108718
- title: create.title,
108719
- subjects: [resolved.subject],
108720
- ...resolved.grade === undefined ? {} : { grades: [resolved.grade] },
108721
- org: { sourcedId: createOrgId },
108722
- status: "active",
108723
- courseCode: resolved.courseCode,
108724
- level: resolved.level,
108725
- metadata
108726
- });
108727
- const allocatedCourseId = response.sourcedIdPairs.allocatedSourcedId;
108728
- updatedConfig.courses[create.index] = {
108729
- ...create.local,
108730
- ids: {
108731
- ...create.local.ids,
108732
- [createEnv]: allocatedCourseId
108733
- }
108734
- };
108735
- result.created++;
108736
- const effectiveLaunchUrl = resolved.launchUrl ?? config7.launchUrl;
108737
- if (effectiveLaunchUrl) {
108738
- onProgress?.(`Ensuring course structure for ${create.title}...`);
108739
- const structureResult = await ensureCourseStructure({
108740
- env: createEnv,
108741
- courseId: allocatedCourseId,
108742
- courseTitle: create.title,
108743
- appName: config7.name,
108744
- launchUrl: effectiveLaunchUrl,
108745
- onProgress
108746
- });
108747
- if (!structureResult.success && structureResult.error) {
108748
- result.errors.push(structureResult.error);
108749
- }
108750
- }
108751
- } catch (error57) {
108752
- const formatted = formatApiError(error57);
108753
- result.errors.push({ title: create.title, ...formatted });
108754
- }
108755
- }
108756
- for (const update of diff.updates) {
108757
- for (const { env: env2, courseId } of update.environments) {
108758
- onProgress?.(`Updating ${update.localTitle} on ${env2}...`);
108759
- const updateResult = await updateCourse({
108760
- env: env2,
108761
- courseId,
108762
- title: update.localTitle,
108763
- course: update.local,
108764
- defaults: config7.defaults
108765
- });
108766
- if (!updateResult.success && updateResult.error) {
108767
- result.errors.push(updateResult.error);
108768
- continue;
108769
- }
108770
- const resolved = resolveCourseForEnv(update.local, config7.defaults, env2);
108771
- const effectiveLaunchUrl = resolved.launchUrl ?? config7.launchUrl;
108772
- if (effectiveLaunchUrl) {
108773
- onProgress?.(`Ensuring course structure for ${update.localTitle}...`);
108774
- const structureResult = await ensureCourseStructure({
108775
- env: env2,
108776
- courseId,
108777
- courseTitle: update.localTitle,
108778
- appName: config7.name,
108779
- launchUrl: effectiveLaunchUrl,
108780
- onProgress
108781
- });
108782
- if (!structureResult.success && structureResult.error) {
108783
- result.errors.push(structureResult.error);
108784
- }
108785
- }
108786
- }
108787
- result.updated++;
108788
- }
108789
- return { result, updatedConfig };
108790
- }
108755
+ // src/commands/init/init.ts
108756
+ import { existsSync as existsSync4 } from "node:fs";
108757
+ import { basename as basename2, resolve as resolve4 } from "node:path";
108791
108758
 
108792
- // src/commands/sync/lib/utils.ts
108759
+ // src/commands/resources/push/lib/utils.ts
108793
108760
  function getGradeLabel(grade) {
108794
108761
  if (grade === -1)
108795
108762
  return "Pre-K";
@@ -108799,996 +108766,20 @@ function getGradeLabel(grade) {
108799
108766
  return "AP";
108800
108767
  return `Grade ${grade}`;
108801
108768
  }
108802
- function getCourseTitle(appName, course) {
108769
+ function getCourseTitle(appName, course, options = {}) {
108803
108770
  if (course.grade !== undefined) {
108804
108771
  return `${appName} - ${getGradeLabel(course.grade)}`;
108805
108772
  }
108806
- if (course.courseCode) {
108807
- return `${appName} - ${course.courseCode}`;
108808
- }
108809
- return `${appName} - ${course.subject}`;
108810
- }
108811
-
108812
- // src/commands/init/lib/constants.ts
108813
- var SUBJECTS = [
108814
- "Math",
108815
- "FastMath",
108816
- "Reading",
108817
- "Language",
108818
- "Vocabulary",
108819
- "Writing",
108820
- "Science",
108821
- "Social Studies"
108822
- ];
108823
- var GRADES = [
108824
- { value: -1, label: "Pre-K" },
108825
- { value: 0, label: "Kindergarten" },
108826
- { value: 1, label: "Grade 1" },
108827
- { value: 2, label: "Grade 2" },
108828
- { value: 3, label: "Grade 3" },
108829
- { value: 4, label: "Grade 4" },
108830
- { value: 5, label: "Grade 5" },
108831
- { value: 6, label: "Grade 6" },
108832
- { value: 7, label: "Grade 7" },
108833
- { value: 8, label: "Grade 8" },
108834
- { value: 9, label: "Grade 9" },
108835
- { value: 10, label: "Grade 10" },
108836
- { value: 11, label: "Grade 11" },
108837
- { value: 12, label: "Grade 12" },
108838
- { value: 13, label: "AP" }
108839
- ];
108840
-
108841
- // src/commands/edit/lib/properties.ts
108842
- var EDITABLE_PROPERTIES = [
108843
- { key: "subject", label: "Subject", type: "select-subject", path: ["subject"] },
108844
- { key: "grade", label: "Grade", type: "select-grade", path: ["grade"] },
108845
- { key: "courseCode", label: "Course Code", type: "text", path: ["courseCode"] },
108846
- { key: "level", label: "Level", type: "text", path: ["level"] },
108847
- {
108848
- key: "courseType",
108849
- label: "Course Type",
108850
- type: "select-enum",
108851
- path: ["metadata", "courseType"],
108852
- options: CourseType6.options.map((v2) => ({ value: v2, label: v2 }))
108853
- },
108854
- {
108855
- key: "publishStatus",
108856
- label: "Publish Status",
108857
- type: "select-enum",
108858
- path: ["metadata", "publishStatus"],
108859
- options: PublishStatus6.options.map((v2) => ({ value: v2, label: v2 }))
108860
- },
108861
- {
108862
- key: "isSupplemental",
108863
- label: "Supplemental?",
108864
- type: "boolean",
108865
- path: ["metadata", "isSupplemental"]
108866
- },
108867
- { key: "isCustom", label: "Custom?", type: "boolean", path: ["metadata", "isCustom"] },
108868
- {
108869
- key: "contactEmail",
108870
- label: "Contact Email",
108871
- type: "text",
108872
- path: ["metadata", "contactEmail"],
108873
- validate: (v2) => {
108874
- if (v2 === "")
108875
- return;
108876
- const result = CourseMetadata6.shape.contactEmail.safeParse(v2);
108877
- if (!result.success)
108878
- return "Must be a valid email address";
108879
- }
108880
- },
108881
- { key: "primaryApp", label: "Primary App", type: "text", path: ["metadata", "primaryApp"] },
108882
- {
108883
- key: "dailyXp",
108884
- label: "Daily XP Goal",
108885
- type: "number",
108886
- path: ["metadata", "goals", "dailyXp"],
108887
- validate: (v2) => {
108888
- const result = CourseGoals6.shape.dailyXp.safeParse(Number(v2));
108889
- if (!result.success)
108890
- return result.error.issues[0]?.message;
108891
- }
108892
- },
108893
- {
108894
- key: "dailyLessons",
108895
- label: "Daily Lessons Goal",
108896
- type: "number",
108897
- path: ["metadata", "goals", "dailyLessons"],
108898
- validate: (v2) => {
108899
- const result = CourseGoals6.shape.dailyLessons.safeParse(Number(v2));
108900
- if (!result.success)
108901
- return result.error.issues[0]?.message;
108902
- }
108903
- },
108904
- {
108905
- key: "dailyActiveMinutes",
108906
- label: "Daily Active Minutes Goal",
108907
- type: "number",
108908
- path: ["metadata", "goals", "dailyActiveMinutes"],
108909
- validate: (v2) => {
108910
- const result = CourseGoals6.shape.dailyActiveMinutes.safeParse(Number(v2));
108911
- if (!result.success)
108912
- return result.error.issues[0]?.message;
108913
- }
108914
- },
108915
- {
108916
- key: "dailyAccuracy",
108917
- label: "Daily Accuracy Goal (%)",
108918
- type: "number",
108919
- path: ["metadata", "goals", "dailyAccuracy"],
108920
- validate: (v2) => {
108921
- const result = CourseGoals6.shape.dailyAccuracy.safeParse(Number(v2));
108922
- if (!result.success)
108923
- return result.error.issues[0]?.message;
108924
- }
108925
- },
108926
- {
108927
- key: "dailyMasteredUnits",
108928
- label: "Daily Mastered Units Goal",
108929
- type: "number",
108930
- path: ["metadata", "goals", "dailyMasteredUnits"],
108931
- validate: (v2) => {
108932
- const result = CourseGoals6.shape.dailyMasteredUnits.safeParse(Number(v2));
108933
- if (!result.success)
108934
- return result.error.issues[0]?.message;
108935
- }
108936
- },
108937
- {
108938
- key: "totalXp",
108939
- label: "Total XP",
108940
- type: "number",
108941
- path: ["metadata", "metrics", "totalXp"],
108942
- validate: (v2) => {
108943
- const result = CourseMetrics6.shape.totalXp.safeParse(Number(v2));
108944
- if (!result.success)
108945
- return result.error.issues[0]?.message;
108946
- }
108947
- },
108948
- {
108949
- key: "totalLessons",
108950
- label: "Total Lessons",
108951
- type: "number",
108952
- path: ["metadata", "metrics", "totalLessons"],
108953
- validate: (v2) => {
108954
- const result = CourseMetrics6.shape.totalLessons.safeParse(Number(v2));
108955
- if (!result.success)
108956
- return result.error.issues[0]?.message;
108957
- }
108958
- },
108959
- {
108960
- key: "totalGrades",
108961
- label: "Total Grades",
108962
- type: "number",
108963
- path: ["metadata", "metrics", "totalGrades"],
108964
- validate: (v2) => {
108965
- const result = CourseMetrics6.shape.totalGrades.safeParse(Number(v2));
108966
- if (!result.success)
108967
- return result.error.issues[0]?.message;
108968
- }
108969
- }
108970
- ];
108971
-
108972
- // src/commands/edit/lib/prompts.ts
108973
- function getNestedValue(obj, path) {
108974
- let current = obj;
108975
- for (const key of path) {
108976
- if (current === null || current === undefined || typeof current !== "object") {
108977
- return;
108978
- }
108979
- current = current[key];
108980
- }
108981
- return current;
108982
- }
108983
- function setNestedValue(obj, path, value) {
108984
- let current = obj;
108985
- for (let i = 0;i < path.length - 1; i++) {
108986
- const key = path[i];
108987
- const val = current[key];
108988
- if (val === null || val === undefined || typeof val !== "object") {
108989
- current[key] = {};
108990
- }
108991
- current = current[key];
108992
- }
108993
- const lastKey = path.at(-1);
108994
- if (lastKey)
108995
- current[lastKey] = value;
108996
- }
108997
- function formatCurrentValue(value) {
108998
- if (value === null || value === undefined)
108999
- return dim("(not set)");
109000
- if (typeof value === "boolean")
109001
- return value ? "yes" : "no";
109002
- return String(value);
109003
- }
109004
- async function promptAction(courseCount) {
109005
- const hasCourses = courseCount > 0;
109006
- const action = await ve({
109007
- message: "What would you like to do?",
109008
- options: [
109009
- { value: "add", label: "Add course(s)" },
109010
- ...hasCourses ? [
109011
- { value: "edit", label: "Edit course(s)" },
109012
- { value: "remove", label: "Remove course(s)" }
109013
- ] : [],
109014
- { value: "done", label: "Done" }
109015
- ]
109016
- });
109017
- if (isCancelled(action))
109018
- return null;
109019
- return action;
109020
- }
109021
- async function promptSelectCourse(courses, message) {
109022
- const choice = await ve({
109023
- message,
109024
- options: courses.map((c, i) => ({
109025
- value: i,
109026
- label: `${c.subject} - Grade ${c.grade}`
109027
- }))
109028
- });
109029
- if (isCancelled(choice))
109030
- return null;
109031
- return choice;
109032
- }
109033
- async function promptAddCourses(existingCourses) {
109034
- const subjects = await fe({
109035
- message: "Select subjects",
109036
- options: SUBJECTS.map((s) => ({ value: s, label: s }))
109037
- });
109038
- if (isCancelled(subjects))
109039
- return null;
109040
- if (subjects.length === 0) {
109041
- M2.error("At least one subject is required");
109042
- return null;
109043
- }
109044
- const grades = await fe({
109045
- message: "Select grade levels",
109046
- options: GRADES
109047
- });
109048
- if (isCancelled(grades))
109049
- return null;
109050
- if (grades.length === 0) {
109051
- M2.error("At least one grade is required");
109052
- return null;
109053
- }
109054
- const newCourses = [];
109055
- let skipped = 0;
109056
- for (const subject of subjects) {
109057
- for (const grade of grades) {
109058
- const exists = existingCourses.some((c) => c.subject === subject && c.grade === grade);
109059
- if (exists) {
109060
- skipped++;
109061
- continue;
109062
- }
109063
- newCourses.push({
109064
- subject,
109065
- grade,
109066
- ids: null
109067
- });
109068
- }
109069
- }
109070
- if (skipped > 0) {
109071
- M2.warn(`Skipped ${skipped} duplicate course${skipped === 1 ? "" : "s"}`);
109072
- }
109073
- return newCourses;
109074
- }
109075
- var ADD_NEW_PROPERTIES_KEY = "__add_new__";
109076
- async function promptEditCourse(course, allCourses, editIndex, remoteData) {
109077
- const configuredProps = EDITABLE_PROPERTIES.filter((prop) => {
109078
- const value = getNestedValue(course, prop.path);
109079
- return value !== null && value !== undefined;
109080
- });
109081
- const unconfiguredProps = EDITABLE_PROPERTIES.filter((prop) => {
109082
- const value = getNestedValue(course, prop.path);
109083
- return value === null || value === undefined;
109084
- });
109085
- const options = [
109086
- ...configuredProps.map((prop) => {
109087
- const currentValue = getNestedValue(course, prop.path);
109088
- return {
109089
- value: prop.key,
109090
- label: `${prop.label} ${dim(`(${formatCurrentValue(currentValue)})`)}`
109091
- };
109092
- }),
109093
- ...unconfiguredProps.length > 0 ? [{ value: ADD_NEW_PROPERTIES_KEY, label: dim("+ Add new properties") }] : []
109094
- ];
109095
- if (options.length === 0) {
109096
- M2.warn("No editable properties available");
109097
- return null;
109098
- }
109099
- const selectedKeys = await fe({
109100
- message: "Select properties to edit",
109101
- options
109102
- });
109103
- if (isCancelled(selectedKeys))
109104
- return null;
109105
- const keys = selectedKeys;
109106
- if (keys.length === 0) {
109107
- M2.warn("No properties selected");
109108
- return null;
109109
- }
109110
- const updated = structuredClone(course);
109111
- let hasChanges = false;
109112
- const wantsNewProps = keys.includes(ADD_NEW_PROPERTIES_KEY);
109113
- const propsToEdit = keys.filter((k3) => k3 !== ADD_NEW_PROPERTIES_KEY);
109114
- for (const key of propsToEdit) {
109115
- const prop = configuredProps.find((p2) => p2.key === key);
109116
- const currentValue = getNestedValue(course, prop.path);
109117
- const newValue = await promptForProperty(prop, currentValue);
109118
- if (newValue === null)
109119
- return null;
109120
- if (newValue !== currentValue) {
109121
- setNestedValue(updated, prop.path, newValue);
109122
- hasChanges = true;
109123
- }
109124
- }
109125
- if (wantsNewProps) {
109126
- const newPropKeys = await fe({
109127
- message: "Select properties to add",
109128
- options: unconfiguredProps.map((prop) => {
109129
- const remoteValue = remoteData ? getNestedValue(remoteData, prop.path) : undefined;
109130
- const hint = remoteValue !== null && remoteValue !== undefined ? dim(` (current: ${formatCurrentValue(remoteValue)})`) : "";
109131
- return {
109132
- value: prop.key,
109133
- label: `${prop.label}${hint}`
109134
- };
109135
- })
109136
- });
109137
- if (isCancelled(newPropKeys))
109138
- return null;
109139
- for (const key of newPropKeys) {
109140
- const prop = unconfiguredProps.find((p2) => p2.key === key);
109141
- const remoteValue = remoteData ? getNestedValue(remoteData, prop.path) : undefined;
109142
- const newValue = await promptForProperty(prop, remoteValue);
109143
- if (newValue === null)
109144
- return null;
109145
- if (newValue !== undefined) {
109146
- setNestedValue(updated, prop.path, newValue);
109147
- hasChanges = true;
109148
- }
109149
- }
109150
- }
109151
- const updatedCourse = updated;
109152
- const isDuplicate = allCourses.some((c, i) => i !== editIndex && c.subject === updatedCourse.subject && c.grade === updatedCourse.grade);
109153
- if (isDuplicate) {
109154
- M2.error(`A course for ${updatedCourse.subject} - Grade ${updatedCourse.grade} already exists`);
109155
- return null;
109156
- }
109157
- if (!hasChanges) {
109158
- M2.warn("No changes made");
109159
- return null;
109160
- }
109161
- return updatedCourse;
109162
- }
109163
- async function promptForProperty(prop, currentValue) {
109164
- switch (prop.type) {
109165
- case "select-subject": {
109166
- const value = await ve({
109167
- message: prop.label,
109168
- options: SUBJECTS.map((s) => ({ value: s, label: s })),
109169
- initialValue: currentValue
109170
- });
109171
- if (isCancelled(value))
109172
- return null;
109173
- return value;
108773
+ if (options.disambiguate) {
108774
+ const suffix = course.courseCode ?? course.subject;
108775
+ if (suffix) {
108776
+ return `${appName} - ${suffix}`;
109174
108777
  }
109175
- case "select-grade": {
109176
- const value = await ve({
109177
- message: prop.label,
109178
- options: GRADES,
109179
- initialValue: currentValue
109180
- });
109181
- if (isCancelled(value))
109182
- return null;
109183
- return value;
109184
- }
109185
- case "select-enum": {
109186
- const options = [{ value: "", label: dim("(clear)") }, ...prop.options];
109187
- const value = await ve({
109188
- message: prop.label,
109189
- options,
109190
- initialValue: currentValue ?? ""
109191
- });
109192
- if (isCancelled(value))
109193
- return null;
109194
- return value === "" ? undefined : value;
109195
- }
109196
- case "text": {
109197
- const initial = currentValue ?? "";
109198
- const value = await he({
109199
- message: prop.label,
109200
- initialValue: initial,
109201
- placeholder: initial ? undefined : "(leave empty to clear)",
109202
- validate: prop.validate
109203
- });
109204
- if (isCancelled(value))
109205
- return null;
109206
- return value === "" ? undefined : value;
109207
- }
109208
- case "number": {
109209
- const initial = currentValue !== null && currentValue !== undefined ? String(currentValue) : "";
109210
- const value = await he({
109211
- message: prop.label,
109212
- initialValue: initial,
109213
- placeholder: initial ? undefined : "(leave empty to clear)",
109214
- validate: (v2) => {
109215
- if (v2 === "")
109216
- return;
109217
- if (!/^\d+$/.test(v2))
109218
- return "Must be a positive integer";
109219
- return prop.validate?.(v2);
109220
- }
109221
- });
109222
- if (isCancelled(value))
109223
- return null;
109224
- return value === "" ? undefined : Number(value);
109225
- }
109226
- case "boolean": {
109227
- const value = await ye({
109228
- message: prop.label,
109229
- initialValue: currentValue ?? false
109230
- });
109231
- if (isCancelled(value))
109232
- return null;
109233
- return value;
109234
- }
109235
- default:
109236
- return currentValue;
109237
108778
  }
109238
- }
109239
- async function promptRemoveCourses(courses) {
109240
- const indices = await fe({
109241
- message: "Select courses to remove",
109242
- options: courses.map((c, i) => ({
109243
- value: i,
109244
- label: `${c.subject} - Grade ${c.grade}`
109245
- }))
109246
- });
109247
- if (isCancelled(indices))
109248
- return null;
109249
- return indices;
108779
+ return appName;
109250
108780
  }
109251
108781
 
109252
- // src/commands/edit/lib/handlers.ts
109253
- async function handleAdd(ctx) {
109254
- const newCourses = await promptAddCourses(ctx.config.courses);
109255
- if (newCourses === null)
109256
- return "cancelled";
109257
- if (newCourses.length === 0)
109258
- return "discarded";
109259
- logAdditions(newCourses);
109260
- const confirmed = await ye({ message: "Sync with Timeback?" });
109261
- if (isCancelled(confirmed))
109262
- return "cancelled";
109263
- if (!confirmed) {
109264
- M2.info("Changes discarded");
109265
- return "discarded";
109266
- }
109267
- const envResult = await resolveEnvironment({ skipIntro: true });
109268
- if (!envResult)
109269
- return "cancelled";
109270
- const authSuccess = await initAuth({ env: envResult.env });
109271
- if (!authSuccess) {
109272
- M2.error("Failed to authenticate");
109273
- return "discarded";
109274
- }
109275
- const authCtx = getAuthContext();
109276
- if (!authCtx.credentials.email) {
109277
- M2.error("Email not configured.");
109278
- M2.info("Run `timeback credentials email` to configure your email.");
109279
- return "discarded";
109280
- }
109281
- const orgResult = await selectOrganization(authCtx.timeback, authCtx.credentials.email);
109282
- if (!orgResult.success) {
109283
- if (orgResult.cancelled)
109284
- return "cancelled";
109285
- M2.error(orgResult.error ?? "Failed to select organization");
109286
- return "discarded";
109287
- }
109288
- const baseIndex = ctx.config.courses.length;
109289
- ctx.config.courses.push(...newCourses);
109290
- const diff = {
109291
- creates: newCourses.map((course, i) => ({
109292
- type: "create",
109293
- index: baseIndex + i,
109294
- local: course,
109295
- title: getCourseTitle(ctx.config.name, course)
109296
- })),
109297
- updates: [],
109298
- unchanged: [],
109299
- errors: []
109300
- };
109301
- const s = Y2();
109302
- s.start("Syncing with Timeback...");
109303
- const { result, updatedConfig } = await applyDiff({
109304
- config: ctx.config,
109305
- diff,
109306
- createEnv: envResult.env,
109307
- createOrgId: orgResult.orgId,
109308
- onProgress: (msg) => s.message(msg)
109309
- });
109310
- s.stop("Sync complete");
109311
- ctx.config.courses = updatedConfig.courses;
109312
- await saveConfig(ctx.configPath, ctx.config);
109313
- M2.success(`Saved ${dim(ctx.relativeConfigPath)}`);
109314
- if (result.created > 0) {
109315
- M2.success(`Created ${bold(result.created)} course${result.created === 1 ? "" : "s"} on Timeback`);
109316
- }
109317
- logErrors(result.errors);
109318
- return "synced";
109319
- }
109320
- async function handleEdit(ctx) {
109321
- const index = await promptSelectCourse(ctx.config.courses, "Select a course to edit");
109322
- if (index === null)
109323
- return "cancelled";
109324
- const original = ctx.config.courses[index];
109325
- const synced = getSyncedCourses(original);
109326
- const remoteData = await fetchRemoteCourseData(synced);
109327
- const edited = await promptEditCourse(original, ctx.config.courses, index, remoteData);
109328
- if (edited === null)
109329
- return "discarded";
109330
- logEdit(original, edited);
109331
- const confirmed = await ye({ message: "Sync with Timeback?" });
109332
- if (isCancelled(confirmed))
109333
- return "cancelled";
109334
- if (!confirmed) {
109335
- M2.info("Changes discarded");
109336
- return "discarded";
109337
- }
109338
- const s = Y2();
109339
- if (synced.length > 0) {
109340
- edited.ids = { ...original.ids };
109341
- const title = getCourseTitle(ctx.config.name, edited);
109342
- for (const { env: env2, courseId } of synced) {
109343
- s.start(`Updating on ${env2}...`);
109344
- const result = await updateCourse({
109345
- env: env2,
109346
- courseId,
109347
- title,
109348
- course: edited,
109349
- defaults: ctx.config.defaults
109350
- });
109351
- if (!result.success) {
109352
- s.stop(`Update failed on ${env2}`);
109353
- if (result.error) {
109354
- logErrors([result.error]);
109355
- }
109356
- return "discarded";
109357
- }
109358
- }
109359
- s.stop(`Updated on ${synced.map((s2) => s2.env).join(" and ")}`);
109360
- } else {
109361
- const envResult = await resolveEnvironment({ skipIntro: true });
109362
- if (!envResult)
109363
- return "cancelled";
109364
- const authSuccess = await initAuth({ env: envResult.env });
109365
- if (!authSuccess) {
109366
- M2.error("Failed to authenticate");
109367
- return "discarded";
109368
- }
109369
- const authCtx = getAuthContext();
109370
- if (!authCtx.credentials.email) {
109371
- M2.error("Email not configured.");
109372
- M2.info("Run `timeback credentials email` to configure your email.");
109373
- return "discarded";
109374
- }
109375
- const orgResult = await selectOrganization(authCtx.timeback, authCtx.credentials.email);
109376
- if (!orgResult.success) {
109377
- if (orgResult.cancelled)
109378
- return "cancelled";
109379
- M2.error(orgResult.error ?? "Failed to select organization");
109380
- return "discarded";
109381
- }
109382
- ctx.config.courses[index] = edited;
109383
- const diff = {
109384
- creates: [
109385
- {
109386
- type: "create",
109387
- index,
109388
- local: edited,
109389
- title: getCourseTitle(ctx.config.name, edited)
109390
- }
109391
- ],
109392
- updates: [],
109393
- unchanged: [],
109394
- errors: []
109395
- };
109396
- s.start("Creating course on Timeback...");
109397
- const { result, updatedConfig } = await applyDiff({
109398
- config: ctx.config,
109399
- diff,
109400
- createEnv: envResult.env,
109401
- createOrgId: orgResult.orgId,
109402
- onProgress: (msg) => s.message(msg)
109403
- });
109404
- s.stop("Sync complete");
109405
- ctx.config.courses = updatedConfig.courses;
109406
- if (result.created > 0) {
109407
- M2.success("Created course on Timeback");
109408
- }
109409
- logErrors(result.errors);
109410
- await saveConfig(ctx.configPath, ctx.config);
109411
- M2.success(`Saved ${dim(ctx.relativeConfigPath)}`);
109412
- return "synced";
109413
- }
109414
- ctx.config.courses[index] = edited;
109415
- await saveConfig(ctx.configPath, ctx.config);
109416
- M2.success(`Saved ${dim(ctx.relativeConfigPath)}`);
109417
- return "synced";
109418
- }
109419
- async function handleRemove(ctx) {
109420
- const indices = await promptRemoveCourses(ctx.config.courses);
109421
- if (indices === null)
109422
- return "cancelled";
109423
- if (indices.length === 0)
109424
- return "discarded";
109425
- const coursesToRemove = indices.map((i) => ctx.config.courses[i]);
109426
- logRemovals(coursesToRemove);
109427
- const confirmed = await ye({
109428
- message: "Remove from config? (Timeback courses are unaffected)"
109429
- });
109430
- if (isCancelled(confirmed))
109431
- return "cancelled";
109432
- if (!confirmed) {
109433
- M2.info("Changes discarded");
109434
- return "discarded";
109435
- }
109436
- const sortedIndices = indices.toSorted((a, b3) => b3 - a);
109437
- for (const i of sortedIndices) {
109438
- ctx.config.courses.splice(i, 1);
109439
- }
109440
- await saveConfig(ctx.configPath, ctx.config);
109441
- M2.success(`Saved ${dim(ctx.relativeConfigPath)}`);
109442
- return "saved";
109443
- }
109444
- // src/commands/edit/edit.ts
109445
- async function editConfig(options = {}) {
109446
- const { configPath: customConfigPath, exitOnComplete = true } = options;
109447
- intro("Timeback Edit");
109448
- const configResult = await loadConfig({ configPath: customConfigPath });
109449
- if (!configResult.success) {
109450
- M2.error(configResult.error);
109451
- outro.error("Edit failed");
109452
- if (exitOnComplete)
109453
- process.exit(1);
109454
- return;
109455
- }
109456
- const { configPath } = configResult;
109457
- const relativeConfigPath = getRelativeConfigPath(configPath);
109458
- const config7 = {
109459
- ...configResult.config,
109460
- courses: [...configResult.config.courses]
109461
- };
109462
- M2.info(`Editing ${dim(relativeConfigPath)}`);
109463
- M2.info(`${bold(config7.courses.length)} course${config7.courses.length === 1 ? "" : "s"} defined`);
109464
- const ctx = { config: config7, configPath, relativeConfigPath };
109465
- let hasChanges = false;
109466
- while (true) {
109467
- const action = await promptAction(config7.courses.length);
109468
- if (action === null) {
109469
- outro.cancelled();
109470
- if (exitOnComplete)
109471
- process.exit(0);
109472
- return;
109473
- }
109474
- if (action === "done")
109475
- break;
109476
- let result;
109477
- if (action === "add")
109478
- result = await handleAdd(ctx);
109479
- else if (action === "edit")
109480
- result = await handleEdit(ctx);
109481
- else
109482
- result = await handleRemove(ctx);
109483
- if (result === "cancelled") {
109484
- outro.cancelled();
109485
- if (exitOnComplete)
109486
- process.exit(0);
109487
- return;
109488
- }
109489
- if (result === "synced" || result === "saved")
109490
- hasChanges = true;
109491
- }
109492
- if (!hasChanges) {
109493
- M2.info("No changes made");
109494
- }
109495
- outro.success();
109496
- if (exitOnComplete)
109497
- process.exit(0);
109498
- }
109499
-
109500
- // src/commands/edit/index.ts
109501
- function registerEditCommand(program2) {
109502
- program2.command("edit").description("Add, edit, or remove courses in local config").option("-c, --config <path>", "Path to the config file").action((opts) => editConfig({ configPath: opts.config }));
109503
- }
109504
-
109505
- // src/commands/import/lib/fetch.ts
109506
- async function fetchRemoteCourse(options) {
109507
- const { courseId, env: env2 } = options;
109508
- try {
109509
- const client = getAuthContext(env2).timeback;
109510
- const remoteCourse = await client.oneroster.courses.get(courseId);
109511
- const course = {
109512
- subject: remoteCourse.subjects?.[0] ?? "None",
109513
- grade: remoteCourse.grades?.[0] ?? 0,
109514
- ids: {
109515
- [env2]: courseId
109516
- }
109517
- };
109518
- if (remoteCourse.courseCode) {
109519
- course.courseCode = remoteCourse.courseCode;
109520
- }
109521
- if (remoteCourse.level) {
109522
- course.level = remoteCourse.level;
109523
- }
109524
- if (remoteCourse.metadata) {
109525
- course.metadata = {
109526
- courseType: remoteCourse.metadata.courseType,
109527
- isSupplemental: remoteCourse.metadata.isSupplemental,
109528
- isCustom: remoteCourse.metadata.isCustom,
109529
- publishStatus: remoteCourse.metadata.publishStatus,
109530
- contactEmail: remoteCourse.metadata.contactEmail,
109531
- primaryApp: remoteCourse.metadata.primaryApp,
109532
- goals: remoteCourse.metadata.goals,
109533
- metrics: remoteCourse.metadata.metrics
109534
- };
109535
- course.metadata = Object.fromEntries(Object.entries(course.metadata).filter(([, v2]) => v2 !== undefined));
109536
- if (Object.keys(course.metadata).length === 0) {
109537
- delete course.metadata;
109538
- }
109539
- }
109540
- return { success: true, course };
109541
- } catch (error57) {
109542
- const formatted = formatApiError(error57);
109543
- return {
109544
- success: false,
109545
- error: `${formatted.header}${formatted.details.length > 0 ? `: ${formatted.details.join(", ")}` : ""}`
109546
- };
109547
- }
109548
- }
109549
- function findExistingCourse(config7, courseId, env2) {
109550
- const index = config7.courses.findIndex((c) => c.ids?.[env2] === courseId);
109551
- if (index === -1)
109552
- return;
109553
- if (!config7.courses[index])
109554
- return;
109555
- return {
109556
- index,
109557
- course: config7.courses[index]
109558
- };
109559
- }
109560
- // src/commands/import/import.ts
109561
- function getExistingCourseIds(config7) {
109562
- const byEnv = {};
109563
- for (const course of config7.courses) {
109564
- for (const [env2, id] of Object.entries(course.ids ?? {})) {
109565
- if (!byEnv[env2])
109566
- byEnv[env2] = [];
109567
- if (id)
109568
- byEnv[env2].push(id);
109569
- }
109570
- }
109571
- return byEnv;
109572
- }
109573
- function formatCourseLabel2(course) {
109574
- if (course.grade !== undefined) {
109575
- return `${course.subject} Grade ${course.grade}`;
109576
- }
109577
- if (course.courseCode) {
109578
- return `${course.subject} (${course.courseCode})`;
109579
- }
109580
- return course.subject;
109581
- }
109582
- function formatExistingCourses(config7) {
109583
- if (config7.courses.length === 0) {
109584
- return dim("No courses configured yet.");
109585
- }
109586
- const byEnv = {};
109587
- for (const course of config7.courses) {
109588
- const label = formatCourseLabel2(course);
109589
- const envs = Object.keys(course.ids ?? {});
109590
- for (const env2 of envs) {
109591
- if (!byEnv[env2])
109592
- byEnv[env2] = [];
109593
- byEnv[env2].push(label);
109594
- }
109595
- }
109596
- const envOrder = ["staging", "production"];
109597
- const sortedEnvs = Object.keys(byEnv).sort((a, b3) => envOrder.indexOf(a) - envOrder.indexOf(b3));
109598
- return sortedEnvs.map((env2) => {
109599
- const courses = byEnv[env2] ?? [];
109600
- const header = bold(env2);
109601
- const list = courses.map((c, i) => ` ${dim(`${i + 1}.`)} ${c}`).join(`
109602
- `);
109603
- return `${header}
109604
- ${list}`;
109605
- }).join(`
109606
-
109607
- `);
109608
- }
109609
- async function importByCourseId(options) {
109610
- const {
109611
- courseId,
109612
- configPath: customConfigPath,
109613
- env: envOption,
109614
- exitOnComplete = true
109615
- } = options;
109616
- intro("Timeback Import");
109617
- const configResult = await loadConfig({ configPath: customConfigPath });
109618
- if (!configResult.success) {
109619
- M2.error(configResult.error);
109620
- outro.error("Import failed");
109621
- if (exitOnComplete)
109622
- process.exit(1);
109623
- return;
109624
- }
109625
- const { config: config7, configPath } = configResult;
109626
- const relativeConfigPath = getRelativeConfigPath(configPath);
109627
- M2.info(`Loaded ${dim(relativeConfigPath)}`);
109628
- const envResult = await resolveEnvironment({
109629
- env: envOption,
109630
- skipIntro: true
109631
- });
109632
- if (!envResult) {
109633
- outro.cancelled();
109634
- if (exitOnComplete)
109635
- process.exit(0);
109636
- return;
109637
- }
109638
- const { env: env2 } = envResult;
109639
- const authOk = await initAuth({ env: env2 });
109640
- if (!authOk) {
109641
- outro.error("Import failed");
109642
- if (exitOnComplete)
109643
- process.exit(1);
109644
- return;
109645
- }
109646
- const s = Y2();
109647
- s.start(`Fetching course ${dim(courseId)} from ${bold(env2)}...`);
109648
- const fetchResult = await fetchRemoteCourse({ courseId, env: env2 });
109649
- if (!fetchResult.success || !fetchResult.course) {
109650
- s.stop("Fetch failed");
109651
- M2.error(fetchResult.error ?? "Unknown error fetching course");
109652
- outro.error("Import failed");
109653
- if (exitOnComplete) {
109654
- process.exit(1);
109655
- }
109656
- return;
109657
- }
109658
- s.stop("Course fetched");
109659
- const remoteCourse = fetchResult.course;
109660
- const existing = findExistingCourse(config7, courseId, env2);
109661
- if (existing) {
109662
- M2.warn(`Course ${blueBright(courseId)} already exists in config at index ${existing.index}`);
109663
- const shouldSync = await ye({
109664
- message: "Sync (update) the existing entry with remote data?",
109665
- initialValue: false
109666
- });
109667
- if (isCancelled(shouldSync) || !shouldSync) {
109668
- outro.cancelled();
109669
- if (exitOnComplete) {
109670
- process.exit(0);
109671
- }
109672
- return;
109673
- }
109674
- config7.courses[existing.index] = {
109675
- ...existing.course,
109676
- ...remoteCourse,
109677
- ids: {
109678
- ...existing.course.ids,
109679
- ...remoteCourse.ids
109680
- }
109681
- };
109682
- await saveConfig(configPath, config7);
109683
- M2.success(`Updated course at index ${existing.index}`);
109684
- } else {
109685
- config7.courses.push(remoteCourse);
109686
- await saveConfig(configPath, config7);
109687
- M2.success(`Added ${bold(formatCourseLabel2(remoteCourse))} to config`);
109688
- }
109689
- M2.success(`Saved ${dim(relativeConfigPath)}`);
109690
- outro.success();
109691
- if (exitOnComplete) {
109692
- process.exit(0);
109693
- }
109694
- }
109695
- async function importInteractive(options) {
109696
- const { configPath, exitOnComplete = true, format = true } = options;
109697
- intro("Timeback Import");
109698
- const existingConfig = await loadConfig({ configPath });
109699
- if (!existingConfig.success && existingConfig.error?.includes("No timeback config found")) {
109700
- const createResult = await ensureConfig({
109701
- configPath,
109702
- skipIntro: true,
109703
- format
109704
- });
109705
- if (!createResult.success) {
109706
- if (createResult.cancelled) {
109707
- outro.cancelled();
109708
- } else if (createResult.error) {
109709
- M2.error(createResult.error);
109710
- outro.error("Import failed");
109711
- }
109712
- if (exitOnComplete)
109713
- process.exit(createResult.cancelled ? 0 : 1);
109714
- return;
109715
- }
109716
- outro.success();
109717
- if (exitOnComplete)
109718
- process.exit(0);
109719
- return;
109720
- }
109721
- if (!existingConfig.success) {
109722
- M2.error(existingConfig.error);
109723
- outro.error("Import failed");
109724
- if (exitOnComplete)
109725
- process.exit(1);
109726
- return;
109727
- }
109728
- const { config: config7 } = existingConfig;
109729
- Me(`${blueBright(bold(config7.name))}
109730
-
109731
- ${formatExistingCourses(config7)}`, `${config7.courses.length} course${config7.courses.length === 1 ? "" : "s"} configured`);
109732
- const shouldImport = await ye({
109733
- message: "Would you like to import more courses?",
109734
- initialValue: true
109735
- });
109736
- if (isCancelled(shouldImport) || !shouldImport) {
109737
- outro.cancelled();
109738
- if (exitOnComplete) {
109739
- process.exit(0);
109740
- }
109741
- return;
109742
- }
109743
- const existingCourseIds = getExistingCourseIds(config7);
109744
- const result = await runImportFlow({
109745
- configPath,
109746
- appName: config7.name,
109747
- existingCourseIds,
109748
- format
109749
- });
109750
- if (!result.success) {
109751
- if (result.cancelled) {
109752
- outro.cancelled();
109753
- } else if (result.error) {
109754
- M2.error(result.error);
109755
- outro.error("Import failed");
109756
- }
109757
- if (exitOnComplete)
109758
- process.exit(result.cancelled ? 0 : 1);
109759
- return;
109760
- }
109761
- outro.success();
109762
- if (exitOnComplete)
109763
- process.exit(0);
109764
- }
109765
- async function importCommand(options) {
109766
- const { courseId, configPath, env: env2, exitOnComplete = true, format = true } = options;
109767
- if (courseId) {
109768
- await importByCourseId({ courseId, configPath, env: env2, exitOnComplete });
109769
- } else {
109770
- await importInteractive({ configPath, exitOnComplete, format });
109771
- }
109772
- }
109773
-
109774
- // src/commands/import/index.ts
109775
- function registerImportCommand(program2) {
109776
- program2.command("import [course-id]").description("Import courses from Timeback (exclude course-id to enter interactive mode)").option("-c, --config <path>", "Path to the config file").option("--env <environment>", "Target environment (staging or production)").option("--no-format", "Do not run prettier on the config file").action(async (courseId, opts) => {
109777
- await importCommand({
109778
- courseId,
109779
- configPath: opts.config,
109780
- env: opts.env,
109781
- format: opts.format
109782
- });
109783
- });
109784
- }
109785
-
109786
- // src/commands/init/create.ts
109787
- import { existsSync as existsSync4 } from "node:fs";
109788
- import { writeFile as writeFile4 } from "node:fs/promises";
109789
- import { basename as basename2, resolve as resolve5 } from "node:path";
109790
-
109791
- // src/commands/sync/lib/compare.ts
108782
+ // src/commands/resources/push/lib/compare.ts
109792
108783
  function compareWithTitle(localTitle, remoteTitle, localConfig, remoteData) {
109793
108784
  const changes = [];
109794
108785
  if (remoteTitle !== localTitle) {
@@ -109806,11 +108797,15 @@ async function compareCourses(options) {
109806
108797
  unchanged: [],
109807
108798
  errors: []
109808
108799
  };
108800
+ const gradelessCourseCount = config7.courses.filter((c) => c?.grade === undefined).length;
108801
+ const disambiguateGradeless = gradelessCourseCount > 1;
109809
108802
  for (let i = 0;i < config7.courses.length; i++) {
109810
108803
  const course = config7.courses[i];
109811
- if (!course)
108804
+ if (!course) {
109812
108805
  continue;
109813
- const expectedTitle = getCourseTitle(config7.name, course);
108806
+ }
108807
+ const disambiguate = disambiguateGradeless && course.grade === undefined;
108808
+ const expectedTitle = getCourseTitle(config7.name, course, { disambiguate });
109814
108809
  const synced = getSyncedCourses(course);
109815
108810
  if (synced.length === 0) {
109816
108811
  diff.creates.push({
@@ -109876,7 +108871,128 @@ async function compareCourses(options) {
109876
108871
  }
109877
108872
  return diff;
109878
108873
  }
109879
- // src/commands/sync/lib/diff.ts
108874
+ // src/commands/resources/push/lib/courses.ts
108875
+ async function applyDiff(options) {
108876
+ const { config: config7, diff, createEnv, createOrgId, onProgress } = options;
108877
+ const result = {
108878
+ created: 0,
108879
+ updated: 0,
108880
+ skipped: diff.unchanged.length,
108881
+ errors: [...diff.errors],
108882
+ skippedStructure: []
108883
+ };
108884
+ const structureAttempted = new Set;
108885
+ const updatedConfig = { ...config7, courses: [...config7.courses] };
108886
+ for (const create of diff.creates) {
108887
+ if (!createEnv || !createOrgId) {
108888
+ result.errors.push({
108889
+ title: create.title,
108890
+ header: "Environment and organization required for new courses",
108891
+ details: []
108892
+ });
108893
+ continue;
108894
+ }
108895
+ const authOk = await initAuth({ env: createEnv });
108896
+ if (!authOk) {
108897
+ result.errors.push({
108898
+ title: create.title,
108899
+ header: `Failed to authenticate for ${createEnv}`,
108900
+ details: []
108901
+ });
108902
+ continue;
108903
+ }
108904
+ onProgress?.(`Creating ${create.title} on ${createEnv}...`);
108905
+ const resolved = resolveCourseForEnv(create.local, config7.defaults, createEnv);
108906
+ const metadata = buildCreateMetadata(resolved);
108907
+ try {
108908
+ const client = getAuthContext(createEnv).timeback;
108909
+ const response = await client.oneroster.courses.create({
108910
+ title: create.title,
108911
+ subjects: [resolved.subject],
108912
+ ...resolved.grade === undefined ? {} : { grades: [resolved.grade] },
108913
+ org: { sourcedId: createOrgId },
108914
+ status: "active",
108915
+ courseCode: resolved.courseCode,
108916
+ level: resolved.level,
108917
+ metadata
108918
+ });
108919
+ const allocatedCourseId = response.sourcedIdPairs.allocatedSourcedId;
108920
+ const createdCourse = await client.oneroster.courses.get(allocatedCourseId);
108921
+ const serverMetadata = createdCourse?.metadata;
108922
+ updatedConfig.courses[create.index] = {
108923
+ ...create.local,
108924
+ ids: {
108925
+ ...create.local.ids,
108926
+ [createEnv]: allocatedCourseId
108927
+ },
108928
+ ...serverMetadata?.goals ? { metadata: { ...create.local.metadata, goals: serverMetadata.goals } } : {}
108929
+ };
108930
+ result.created++;
108931
+ const effectiveLaunchUrl = resolved.launchUrl ?? config7.launchUrl;
108932
+ if (effectiveLaunchUrl) {
108933
+ structureAttempted.add(create.title);
108934
+ onProgress?.(`Ensuring course structure for ${create.title}...`);
108935
+ const structureResult = await ensureCourseStructure({
108936
+ env: createEnv,
108937
+ courseId: allocatedCourseId,
108938
+ courseTitle: create.title,
108939
+ appName: config7.name,
108940
+ launchUrl: effectiveLaunchUrl,
108941
+ onProgress
108942
+ });
108943
+ if (!structureResult.success && structureResult.error) {
108944
+ result.errors.push(structureResult.error);
108945
+ }
108946
+ } else if (!result.skippedStructure.includes(create.title)) {
108947
+ result.skippedStructure.push(create.title);
108948
+ }
108949
+ } catch (error57) {
108950
+ const formatted = formatApiError(error57);
108951
+ result.errors.push({ title: create.title, ...formatted });
108952
+ }
108953
+ }
108954
+ for (const update of diff.updates) {
108955
+ for (const { env: env2, courseId } of update.environments) {
108956
+ onProgress?.(`Updating ${update.localTitle} on ${env2}...`);
108957
+ const updateResult = await updateCourse({
108958
+ env: env2,
108959
+ courseId,
108960
+ title: update.localTitle,
108961
+ course: update.local,
108962
+ defaults: config7.defaults
108963
+ });
108964
+ if (!updateResult.success && updateResult.error) {
108965
+ result.errors.push(updateResult.error);
108966
+ continue;
108967
+ }
108968
+ const resolved = resolveCourseForEnv(update.local, config7.defaults, env2);
108969
+ const effectiveLaunchUrl = resolved.launchUrl ?? config7.launchUrl;
108970
+ if (effectiveLaunchUrl) {
108971
+ structureAttempted.add(update.localTitle);
108972
+ onProgress?.(`Ensuring course structure for ${update.localTitle}...`);
108973
+ const structureResult = await ensureCourseStructure({
108974
+ env: env2,
108975
+ courseId,
108976
+ courseTitle: update.localTitle,
108977
+ appName: config7.name,
108978
+ launchUrl: effectiveLaunchUrl,
108979
+ onProgress
108980
+ });
108981
+ if (!structureResult.success && structureResult.error) {
108982
+ result.errors.push(structureResult.error);
108983
+ }
108984
+ }
108985
+ }
108986
+ if (!structureAttempted.has(update.localTitle)) {
108987
+ if (!result.skippedStructure.includes(update.localTitle)) {
108988
+ result.skippedStructure.push(update.localTitle);
108989
+ }
108990
+ }
108991
+ result.updated++;
108992
+ }
108993
+ return { result, updatedConfig };
108994
+ }
108995
+ // src/commands/resources/push/lib/diff.ts
109880
108996
  function formatChange(update) {
109881
108997
  const { changes } = update;
109882
108998
  const maxFieldLen = Math.max(...changes.map((c) => c.field.length));
@@ -109885,30 +109001,59 @@ function formatChange(update) {
109885
109001
  const paddedField = change.field.padStart(maxFieldLen);
109886
109002
  const localVal = change.local || dim("(empty)");
109887
109003
  const paddedRemote = (change.remote || "(empty)").padStart(maxRemoteLen);
109888
- return ` ${dim(`${paddedField}:`)} ${red(paddedRemote)} ${dim("→")} ${green(localVal)}`;
109004
+ return ` ${dim(paddedField)} ${red(paddedRemote)} ${dim("→")} ${green(localVal)}`;
109889
109005
  });
109890
109006
  }
109891
- function displaySyncDiff(diff) {
109007
+ function displayDiff(diff, options) {
109008
+ const { createEnv } = options ?? {};
109892
109009
  const hasChanges = diff.creates.length > 0 || diff.updates.length > 0;
109893
109010
  if (!hasChanges) {
109894
109011
  return;
109895
109012
  }
109896
- M2.message(bold("Changes:"));
109897
- const lines = [];
109898
109013
  for (const create of diff.creates) {
109899
- lines.push(` ${green("+")} ${create.title}`);
109014
+ const lines = [];
109015
+ const { local } = create;
109016
+ const courseCode = local.courseCode ? ` (${local.courseCode})` : "";
109017
+ lines.push(`${bold(create.title)}${dim(courseCode)}`);
109018
+ lines.push("");
109019
+ const targetInfo = createEnv ? dim((createEnv === "production" ? magenta : cyan)(createEnv)) : dim("(new)");
109020
+ lines.push(` ${green("+ create")} ${targetInfo}`);
109021
+ lines.push("");
109022
+ const fields = [
109023
+ { label: "title", value: create.title },
109024
+ { label: "subject", value: local.subject },
109025
+ { label: "grade", value: local.grade },
109026
+ { label: "courseCode", value: local.courseCode }
109027
+ ].filter((f2) => f2.value !== undefined && f2.value !== null);
109028
+ const maxFieldLen = Math.max(...fields.map((f2) => f2.label.length));
109029
+ for (const field of fields) {
109030
+ lines.push(` ${dim(field.label.padStart(maxFieldLen))} ${green(field.value)}`);
109031
+ }
109032
+ M2.message(lines.join(`
109033
+ `), { symbol: green("●") });
109900
109034
  }
109901
109035
  for (const update of diff.updates) {
109036
+ const lines = [];
109037
+ const { local } = update;
109038
+ const courseCode = local.courseCode ? ` (${local.courseCode})` : "";
109039
+ lines.push(`${bold(update.localTitle)}${dim(courseCode)}`);
109040
+ lines.push("");
109041
+ const targetInfo = update.environments.map((env2) => {
109042
+ const envColor = env2.env === "production" ? magenta : cyan;
109043
+ const shortId = env2.courseId.slice(-4);
109044
+ return `${dim(envColor(`${env2.env}:`))}${dim(`..${shortId}`)}`;
109045
+ }).join(" ");
109046
+ lines.push(` ${yellow("~ update")} ${targetInfo}`);
109047
+ lines.push("");
109902
109048
  lines.push(...formatChange(update));
109049
+ M2.message(lines.join(`
109050
+ `), { symbol: yellow("●") });
109903
109051
  }
109904
109052
  if (diff.unchanged.length > 0) {
109905
- lines.push("");
109906
- lines.push(dim(` ${diff.unchanged.length} course${diff.unchanged.length === 1 ? "" : "s"} unchanged`));
109053
+ M2.message(dim(`${diff.unchanged.length} course${diff.unchanged.length === 1 ? "" : "s"} unchanged`));
109907
109054
  }
109908
- M2.message(lines.join(`
109909
- `));
109910
109055
  }
109911
- function getSyncSummary(diff) {
109056
+ function getDiffSummary(diff) {
109912
109057
  const parts = [];
109913
109058
  if (diff.creates.length > 0) {
109914
109059
  parts.push(`${bold(diff.creates.length)} to create`);
@@ -109921,11 +109066,125 @@ function getSyncSummary(diff) {
109921
109066
  }
109922
109067
  return parts.join(", ");
109923
109068
  }
109924
- // src/commands/sync/lib/setup.ts
109069
+ // src/commands/resources/push/lib/metrics.ts
109070
+ function findMissingCourseMetrics(config7) {
109071
+ const issues = [];
109072
+ const gradelessCourseCount = config7.courses.filter((c) => c?.grade === undefined).length;
109073
+ const disambiguateGradeless = gradelessCourseCount > 1;
109074
+ for (let i = 0;i < config7.courses.length; i++) {
109075
+ const course = config7.courses[i];
109076
+ if (!course) {
109077
+ continue;
109078
+ }
109079
+ const disambiguate = disambiguateGradeless && course.grade === undefined;
109080
+ const title = getCourseTitle(config7.name, course, { disambiguate });
109081
+ const metrics = course.metadata?.metrics;
109082
+ if (metrics?.totalXp === undefined) {
109083
+ issues.push({
109084
+ courseIndex: i,
109085
+ courseTitle: title,
109086
+ metric: "totalXp",
109087
+ jsonPath: `courses[${i}].metadata.metrics.totalXp`
109088
+ });
109089
+ }
109090
+ if (metrics?.totalLessons === undefined) {
109091
+ issues.push({
109092
+ courseIndex: i,
109093
+ courseTitle: title,
109094
+ metric: "totalLessons",
109095
+ jsonPath: `courses[${i}].metadata.metrics.totalLessons`
109096
+ });
109097
+ }
109098
+ }
109099
+ return issues;
109100
+ }
109101
+ function formatMissingMetricsError(issues) {
109102
+ if (issues.length === 0) {
109103
+ return [];
109104
+ }
109105
+ const lines = ["Missing required course metrics:", ""];
109106
+ const byCourse = new Map;
109107
+ for (const issue4 of issues) {
109108
+ const existing = byCourse.get(issue4.courseIndex) ?? [];
109109
+ existing.push(issue4);
109110
+ byCourse.set(issue4.courseIndex, existing);
109111
+ }
109112
+ for (const [, courseIssues] of byCourse) {
109113
+ const first = courseIssues[0];
109114
+ if (!first) {
109115
+ continue;
109116
+ }
109117
+ lines.push(` ${bold(first.courseTitle)}:`);
109118
+ for (const issue4 of courseIssues) {
109119
+ lines.push(` - ${issue4.metric} (${dim(issue4.jsonPath)})`);
109120
+ }
109121
+ }
109122
+ lines.push("");
109123
+ lines.push("Add these fields to your timeback.config.json and try again.");
109124
+ return lines;
109125
+ }
109126
+ async function promptForMissingMetrics(issues, config7) {
109127
+ if (issues.length === 0) {
109128
+ return config7;
109129
+ }
109130
+ M2.warn(yellow("Some courses are missing required metrics."));
109131
+ const byCourse = new Map;
109132
+ for (const issue4 of issues) {
109133
+ const existing = byCourse.get(issue4.courseIndex) ?? [];
109134
+ existing.push(issue4);
109135
+ byCourse.set(issue4.courseIndex, existing);
109136
+ }
109137
+ for (const [courseIndex, courseIssues] of byCourse) {
109138
+ const course = config7.courses[courseIndex];
109139
+ const first = courseIssues[0];
109140
+ if (!course || !first) {
109141
+ continue;
109142
+ }
109143
+ M2.info(cyan(bold(first.courseTitle)));
109144
+ for (const issue4 of courseIssues) {
109145
+ const value = await promptForMetric(issue4.metric);
109146
+ if (value === null) {
109147
+ return null;
109148
+ }
109149
+ course.metadata = course.metadata ?? {};
109150
+ course.metadata.metrics = course.metadata.metrics ?? {};
109151
+ course.metadata.metrics[issue4.metric] = value;
109152
+ }
109153
+ }
109154
+ return config7;
109155
+ }
109156
+ async function promptForMetric(metric) {
109157
+ const labels = {
109158
+ totalXp: "Total course XP",
109159
+ totalLessons: "Total course lessons/units"
109160
+ };
109161
+ const result = await he({
109162
+ message: labels[metric],
109163
+ placeholder: "Enter a positive integer",
109164
+ validate: (value) => {
109165
+ const trimmed = value.trim();
109166
+ if (!trimmed) {
109167
+ return "Value is required";
109168
+ }
109169
+ const num = Number(trimmed);
109170
+ if (!Number.isInteger(num) || num <= 0) {
109171
+ return "Must be a positive integer";
109172
+ }
109173
+ }
109174
+ });
109175
+ if (isCancelled(result)) {
109176
+ return null;
109177
+ }
109178
+ return Number(result);
109179
+ }
109180
+ function isInteractive() {
109181
+ return process.stdout.isTTY === true && process.stdin.isTTY === true;
109182
+ }
109183
+ // src/commands/resources/push/lib/setup.ts
109925
109184
  async function setupCreateContext(options = {}) {
109926
109185
  if (options.env && !isTargetEnvironment(options.env)) {
109927
109186
  M2.error(`Invalid environment: ${options.env}. Must be 'staging' or 'production'.`);
109928
- outro.error("Sync failed");
109187
+ outro.error("Push failed");
109929
109188
  return { success: false, exitCode: 1 };
109930
109189
  }
109931
109190
  const envResult = await resolveEnvironment({ env: options.env, skipIntro: true });
@@ -109935,24 +109194,24 @@ async function setupCreateContext(options = {}) {
109935
109194
  }
109936
109195
  const authOk = await initAuth({ env: envResult.env });
109937
109196
  if (!authOk) {
109938
- outro.error("Sync failed");
109197
+ outro.error("Push failed");
109939
109198
  return { success: false, exitCode: 1 };
109940
109199
  }
109941
109200
  const ctx = getAuthContext(envResult.env);
109942
109201
  if (!ctx.credentials.email) {
109943
109202
  M2.error("Email not configured.");
109944
109203
  M2.info("Run `timeback credentials email` to configure your email.");
109945
- outro.error("Sync failed");
109204
+ outro.error("Push failed");
109946
109205
  return { success: false, exitCode: 1 };
109947
109206
  }
109948
- const orgResult = await selectOrganization(ctx.timeback, ctx.credentials.email);
109207
+ const orgResult = await selectOrganization(ctx.timeback, ctx.credentials.email, options.courseCount);
109949
109208
  if (!orgResult.success) {
109950
109209
  if (orgResult.cancelled) {
109951
109210
  outro.cancelled();
109952
109211
  return { success: false, exitCode: 0 };
109953
109212
  }
109954
109213
  M2.error(orgResult.error);
109955
- outro.error("Sync failed");
109214
+ outro.error("Push failed");
109956
109215
  return { success: false, exitCode: 1 };
109957
109216
  }
109958
109217
  M2.info(`Creating on ${bold(envResult.env)} in ${bold(orgResult.orgName)}`);
@@ -109961,8 +109220,77 @@ async function setupCreateContext(options = {}) {
109961
109220
  context: { env: envResult.env, orgId: orgResult.orgId }
109962
109221
  };
109963
109222
  }
109964
- // src/commands/sync/sync.ts
109965
- async function syncCommand(options) {
109223
+ // src/commands/resources/push/lib/warnings.ts
109224
+ function formatSkippedStructureWarning(titles) {
109225
+ const uniqueTitles = [...new Set(titles)].filter(Boolean);
109226
+ if (uniqueTitles.length === 0)
109227
+ return null;
109228
+ return {
109229
+ warning: `${uniqueTitles.length} ${pluralize(uniqueTitles.length, "course")} synced but not launchable (missing launchUrl)`,
109230
+ info: [
109231
+ ...uniqueTitles.map((title) => ` • ${title}`),
109232
+ "Students won't be able to launch these courses until you add a launchUrl.",
109233
+ "Add launchUrl to your config and run sync again to fix this."
109234
+ ]
109235
+ };
109236
+ }
109237
+
109238
+ // src/commands/resources/push/push.ts
109239
+ function logPushResults(result) {
109240
+ if (result.created > 0) {
109241
+ M2.success(`Created ${bold(result.created)} course${result.created === 1 ? "" : "s"}`);
109242
+ }
109243
+ if (result.updated > 0) {
109244
+ M2.success(`Updated ${bold(result.updated)} course${result.updated === 1 ? "" : "s"}`);
109245
+ }
109246
+ logErrors(result.errors);
109247
+ if (result.skippedStructure.length > 0) {
109248
+ const warning = formatSkippedStructureWarning(result.skippedStructure);
109249
+ if (warning) {
109250
+ M2.warn(warning.warning);
109251
+ for (const line of warning.info) {
109252
+ M2.info(line);
109253
+ }
109254
+ }
109255
+ }
109256
+ }
109257
+ async function validateMetricsGate(config7, configPath, relativeConfigPath) {
109258
+ const missingMetrics = findMissingCourseMetrics(config7);
109259
+ if (missingMetrics.length === 0) {
109260
+ return { success: true, config: config7 };
109261
+ }
109262
+ if (isInteractive()) {
109263
+ const updatedConfig = await promptForMissingMetrics(missingMetrics, config7);
109264
+ if (updatedConfig === null) {
109265
+ outro.cancelled();
109266
+ return { success: false, exitCode: 0 };
109267
+ }
109268
+ await saveConfig(configPath, updatedConfig);
109269
+ M2.success(`Updated ${dim(relativeConfigPath)} with metrics`);
109270
+ return { success: true, config: updatedConfig };
109271
+ }
109272
+ const errorLines = formatMissingMetricsError(missingMetrics);
109273
+ for (const line of errorLines) {
109274
+ M2.error(line);
109275
+ }
109276
+ outro.error("Push failed");
109277
+ return { success: false, exitCode: 1 };
109278
+ }
109279
+ async function computeDiff(config7, initMode) {
109280
+ const hasLinkedCourses = config7.courses.some((course) => getSyncedCourses(course).length > 0);
109281
+ if (initMode && !hasLinkedCourses) {
109282
+ return compareCourses({ config: config7 });
109283
+ }
109284
+ const s = Y2();
109285
+ s.start(hasLinkedCourses ? "Comparing with Timeback..." : "Computing changes...");
109286
+ const diff = await compareCourses({
109287
+ config: config7,
109288
+ onProgress: (msg) => s.message(msg)
109289
+ });
109290
+ s.stop(hasLinkedCourses ? "Remote check complete" : "Changes computed");
109291
+ return diff;
109292
+ }
109293
+ async function pushCommand(options) {
109966
109294
  const {
109967
109295
  configPath: customConfigPath,
109968
109296
  exitOnComplete = true,
@@ -109971,10 +109299,11 @@ async function syncCommand(options) {
109971
109299
  env: env2,
109972
109300
  createEnv: preResolvedEnv,
109973
109301
  preloadedConfig,
109974
- yes = false
109302
+ yes = false,
109303
+ initMode = false
109975
109304
  } = options;
109976
109305
  if (!skipIntro) {
109977
- intro("Timeback Sync");
109306
+ intro("Timeback Push");
109978
109307
  }
109979
109308
  let config7, configPath;
109980
109309
  if (preloadedConfig) {
@@ -109984,7 +109313,7 @@ async function syncCommand(options) {
109984
109313
  const configResult = await loadConfig({ configPath: customConfigPath });
109985
109314
  if (!configResult.success) {
109986
109315
  M2.error(configResult.error);
109987
- outro.error("Sync failed");
109316
+ outro.error("Push failed");
109988
109317
  if (exitOnComplete)
109989
109318
  process.exit(1);
109990
109319
  return false;
@@ -109993,14 +109322,17 @@ async function syncCommand(options) {
109993
109322
  configPath = configResult.configPath;
109994
109323
  }
109995
109324
  const relativeConfigPath = getRelativeConfigPath(configPath);
109996
- M2.info(`Loaded ${dim(relativeConfigPath)}`);
109997
- const s = Y2();
109998
- s.start("Comparing with Timeback...");
109999
- const diff = await compareCourses({
110000
- config: config7,
110001
- onProgress: (msg) => s.message(msg)
110002
- });
110003
- s.stop("Remote check complete");
109325
+ if (!initMode) {
109326
+ M2.info(`Loaded ${dim(relativeConfigPath)}`);
109327
+ }
109328
+ const metricsResult = await validateMetricsGate(config7, configPath, relativeConfigPath);
109329
+ if (!metricsResult.success) {
109330
+ if (exitOnComplete)
109331
+ process.exit(metricsResult.exitCode);
109332
+ return metricsResult.exitCode === 0;
109333
+ }
109334
+ config7 = metricsResult.config;
109335
+ const diff = await computeDiff(config7, initMode);
110004
109336
  if (diff.errors.length > 0) {
110005
109337
  logErrors(diff.errors);
110006
109338
  }
@@ -110012,25 +109344,24 @@ async function syncCommand(options) {
110012
109344
  process.exit(0);
110013
109345
  return true;
110014
109346
  }
110015
- M2.info(`Found changes: ${getSyncSummary(diff)}`);
110016
- displaySyncDiff(diff);
109347
+ if (!initMode) {
109348
+ M2.info(`Found changes: ${getDiffSummary(diff)}`);
109349
+ }
110017
109350
  if (dryRun) {
109351
+ displayDiff(diff);
110018
109352
  outro.warn("Dry run (no changes made)");
110019
- if (exitOnComplete)
110020
- process.exit(0);
110021
- return true;
110022
- }
110023
- const shouldSync = yes || await ye({ message: "Apply these changes?" });
110024
- if (isCancelled(shouldSync) || !shouldSync) {
110025
- outro.cancelled();
110026
- if (exitOnComplete)
109353
+ if (exitOnComplete) {
110027
109354
  process.exit(0);
109355
+ }
110028
109356
  return true;
110029
109357
  }
110030
109358
  let createEnv;
110031
109359
  let createOrgId;
110032
109360
  if (diff.creates.length > 0) {
110033
- const setupResult = await setupCreateContext({ env: env2 ?? preResolvedEnv });
109361
+ const setupResult = await setupCreateContext({
109362
+ env: env2 ?? preResolvedEnv,
109363
+ courseCount: diff.creates.length
109364
+ });
110034
109365
  if (!setupResult.success) {
110035
109366
  if (exitOnComplete)
110036
109367
  process.exit(setupResult.exitCode);
@@ -110039,26 +109370,31 @@ async function syncCommand(options) {
110039
109370
  createEnv = setupResult.context.env;
110040
109371
  createOrgId = setupResult.context.orgId;
110041
109372
  }
110042
- s.start("Applying changes...");
109373
+ displayDiff(diff, { createEnv, initMode });
109374
+ const count = diff.creates.length + diff.updates.length;
109375
+ const msg = initMode ? `Create ${count === 1 ? "this" : "these"} course${count === 1 ? "" : "s"}?` : "Apply these changes?";
109376
+ const shouldSync = yes || await ye({ message: msg });
109377
+ if (isCancelled(shouldSync) || !shouldSync) {
109378
+ outro.cancelled();
109379
+ if (exitOnComplete)
109380
+ process.exit(0);
109381
+ return true;
109382
+ }
109383
+ const applySpinner = Y2();
109384
+ applySpinner.start(initMode ? "Creating courses..." : "Applying changes...");
110043
109385
  const { result, updatedConfig } = await applyDiff({
110044
109386
  config: config7,
110045
109387
  diff,
110046
109388
  createEnv,
110047
109389
  createOrgId,
110048
- onProgress: (msg) => s.message(msg)
109390
+ onProgress: (msg2) => applySpinner.message(msg2)
110049
109391
  });
110050
- s.stop("Changes applied");
109392
+ applySpinner.stop(initMode ? "Courses created" : "Changes applied");
110051
109393
  if (result.created > 0 || result.updated > 0) {
110052
109394
  await saveConfig(configPath, updatedConfig);
110053
- M2.success(`Updated ${dim(relativeConfigPath)}`);
109395
+ M2.success(`Saved ${dim(relativeConfigPath)}`);
110054
109396
  }
110055
- if (result.created > 0) {
110056
- M2.success(`Created ${bold(result.created)} course${result.created === 1 ? "" : "s"}`);
110057
- }
110058
- if (result.updated > 0) {
110059
- M2.success(`Updated ${bold(result.updated)} course${result.updated === 1 ? "" : "s"}`);
110060
- }
110061
- logErrors(result.errors);
109397
+ logPushResults(result);
110062
109398
  if (result.errors.length > 0) {
110063
109399
  outro.warn("Completed with errors");
110064
109400
  if (exitOnComplete)
@@ -110066,8 +109402,9 @@ async function syncCommand(options) {
110066
109402
  return false;
110067
109403
  }
110068
109404
  outro.success();
110069
- if (exitOnComplete)
109405
+ if (exitOnComplete) {
110070
109406
  process.exit(0);
109407
+ }
110071
109408
  return true;
110072
109409
  }
110073
109410
 
@@ -110126,22 +109463,6 @@ async function ensureConfiguredEnvironments(targetEnv) {
110126
109463
  return { status: "ok", envs: configuredEnvs };
110127
109464
  }
110128
109465
  // src/commands/init/lib/postInit.ts
110129
- async function decideSyncAfterInit(opts) {
110130
- if (opts.noSync) {
110131
- return { shouldSync: false, cancelled: false };
110132
- }
110133
- if (opts.yes) {
110134
- return { shouldSync: true, cancelled: false };
110135
- }
110136
- const syncConfirm = await ye({
110137
- message: "Sync with Timeback now?",
110138
- initialValue: true
110139
- });
110140
- if (isCancelled(syncConfirm)) {
110141
- return { shouldSync: false, cancelled: true };
110142
- }
110143
- return { shouldSync: syncConfirm, cancelled: false };
110144
- }
110145
109466
  async function ensureSyncEmail(targetEnv) {
110146
109467
  const creds = await getSavedCredentials(targetEnv);
110147
109468
  if (creds && !creds.email) {
@@ -110152,7 +109473,7 @@ async function ensureSyncEmail(targetEnv) {
110152
109473
  function finishWithoutSync(options) {
110153
109474
  const { withHint, cancelled, exitOnComplete } = options;
110154
109475
  if (withHint) {
110155
- M2.info(`Run ${greenBright("timeback sync")} when you're ready.`);
109476
+ M2.info(`Run ${greenBright("timeback resources push")} once ready`);
110156
109477
  }
110157
109478
  if (cancelled) {
110158
109479
  outro.cancelled();
@@ -110205,6 +109526,35 @@ async function resolveInitSyncCreateEnv(options) {
110205
109526
  }
110206
109527
  return { success: true, createEnv };
110207
109528
  }
109529
+ // src/commands/init/lib/constants.ts
109530
+ var SUBJECTS = [
109531
+ "Math",
109532
+ "FastMath",
109533
+ "Reading",
109534
+ "Language",
109535
+ "Vocabulary",
109536
+ "Writing",
109537
+ "Science",
109538
+ "Social Studies"
109539
+ ];
109540
+ var GRADES = [
109541
+ { value: -1, label: "Pre-K" },
109542
+ { value: 0, label: "Kindergarten" },
109543
+ { value: 1, label: "Grade 1" },
109544
+ { value: 2, label: "Grade 2" },
109545
+ { value: 3, label: "Grade 3" },
109546
+ { value: 4, label: "Grade 4" },
109547
+ { value: 5, label: "Grade 5" },
109548
+ { value: 6, label: "Grade 6" },
109549
+ { value: 7, label: "Grade 7" },
109550
+ { value: 8, label: "Grade 8" },
109551
+ { value: 9, label: "Grade 9" },
109552
+ { value: 10, label: "Grade 10" },
109553
+ { value: 11, label: "Grade 11" },
109554
+ { value: 12, label: "Grade 12" },
109555
+ { value: 13, label: "AP" }
109556
+ ];
109557
+
110208
109558
  // src/commands/init/lib/prompts.ts
110209
109559
  var GRADE_NONE = "none";
110210
109560
  var GRADE_OPTIONS = [
@@ -110289,10 +109639,9 @@ async function promptCourses(appName) {
110289
109639
  }
110290
109640
  }
110291
109641
  }
110292
- M2.info(`Creating ${courses.length} course${courses.length === 1 ? "" : "s"}`);
110293
109642
  return courses;
110294
109643
  }
110295
- // src/commands/init/create.ts
109644
+ // src/commands/init/init.ts
110296
109645
  async function promptInitMode() {
110297
109646
  const mode = await ve({
110298
109647
  message: "How would you like to set up your config?",
@@ -110300,12 +109649,12 @@ async function promptInitMode() {
110300
109649
  {
110301
109650
  value: "create",
110302
109651
  label: "Initialize a new app",
110303
- hint: "Manually define subjects and grades"
109652
+ hint: "First time setup"
110304
109653
  },
110305
109654
  {
110306
109655
  value: "import",
110307
109656
  label: "Import an existing app",
110308
- hint: "Search and import courses from Timeback"
109657
+ hint: "Import existing resources"
110309
109658
  }
110310
109659
  ]
110311
109660
  });
@@ -110329,21 +109678,19 @@ async function createFromScratch() {
110329
109678
  if (launchUrl === undefined) {
110330
109679
  return null;
110331
109680
  }
110332
- const suggestedSensor = deriveSensorFromLaunchUrl(launchUrl);
110333
- const sensor = await promptSensor({ defaultValue: suggestedSensor });
110334
- if (sensor === undefined) {
110335
- return null;
110336
- }
110337
109681
  return {
110338
109682
  name,
110339
109683
  launchUrl,
110340
- sensor,
110341
109684
  courses: courses.map((c) => ({
110342
109685
  ...c,
110343
109686
  ids: null
110344
109687
  }))
110345
109688
  };
110346
109689
  }
109690
+ function extractCommonLaunchUrl(courses) {
109691
+ const urls = new Set(courses.map((c) => c.launchUrl).filter(Boolean));
109692
+ return urls.size === 1 ? [...urls][0] : undefined;
109693
+ }
110347
109694
  async function createFromImport() {
110348
109695
  const setupResult = await ensureConfiguredEnvironments();
110349
109696
  if (setupResult.status === "cancelled") {
@@ -110360,18 +109707,16 @@ async function createFromImport() {
110360
109707
  }
110361
109708
  return null;
110362
109709
  }
110363
- const launchUrl = await promptLaunchUrl();
110364
- if (launchUrl === undefined) {
110365
- return null;
110366
- }
110367
- const sensor = await promptSensor({ defaultValue: deriveSensorFromLaunchUrl(launchUrl) });
110368
- if (sensor === undefined) {
110369
- return null;
109710
+ let launchUrl = extractCommonLaunchUrl(result.courses);
109711
+ if (!launchUrl) {
109712
+ launchUrl = await promptLaunchUrl();
109713
+ if (launchUrl === undefined) {
109714
+ return null;
109715
+ }
110370
109716
  }
110371
109717
  return {
110372
109718
  name: result.appName,
110373
109719
  launchUrl,
110374
- sensor,
110375
109720
  courses: result.courses
110376
109721
  };
110377
109722
  }
@@ -110380,15 +109725,16 @@ async function createConfig(options = {}) {
110380
109725
  configPath: customPath,
110381
109726
  exitOnComplete = true,
110382
109727
  format = true,
110383
- env: env2,
109728
+ syncEnv,
110384
109729
  yes = false,
110385
- noSync = false
109730
+ sync = false
110386
109731
  } = options;
110387
109732
  const cwd = process.cwd();
110388
- const configPath = customPath ? resolve5(cwd, customPath) : resolve5(cwd, DEFAULT_CONFIG_FILENAME);
109733
+ const configPath = customPath ? resolve4(cwd, customPath) : resolve4(cwd, DEFAULT_CONFIG_FILENAME);
110389
109734
  const configFilename = basename2(configPath);
110390
- intro("Timeback");
110391
- if (existsSync4(configPath)) {
109735
+ intro("Timeback Init");
109736
+ const configExisted = existsSync4(configPath);
109737
+ if (configExisted) {
110392
109738
  M2.warn(`${blueBright(configFilename)} already exists`);
110393
109739
  const overwrite = yes ? true : await ye({
110394
109740
  message: "Overwrite existing config?",
@@ -110420,32 +109766,18 @@ async function createConfig(options = {}) {
110420
109766
  process.exit(0);
110421
109767
  return;
110422
109768
  }
109769
+ const normalizedConfig = normalizeLaunchUrls(config7);
110423
109770
  const s = Y2();
110424
109771
  s.start(`Creating ${configFilename}...`);
110425
- const content = generateConfigContent(config7);
110426
- await writeFile4(configPath, content, "utf-8");
110427
- if (format) {
110428
- await formatWithPrettier({ cwd, filePath: configPath, logger: M2, silent: true });
110429
- }
109772
+ await saveConfig(configPath, normalizedConfig, { format });
110430
109773
  s.stop(`Created ${dim(configFilename)}`);
110431
- if (mode === "import") {
110432
- outro.success();
110433
- if (exitOnComplete)
110434
- process.exit(0);
110435
- return;
110436
- }
110437
- const syncDecision = await decideSyncAfterInit({ noSync, yes });
110438
- if (syncDecision.cancelled) {
110439
- finishWithoutSync({ withHint: false, cancelled: true, exitOnComplete });
110440
- return;
110441
- }
110442
- if (!syncDecision.shouldSync) {
109774
+ if (!sync) {
110443
109775
  finishWithoutSync({ withHint: true, cancelled: false, exitOnComplete });
110444
109776
  return;
110445
109777
  }
110446
109778
  const createEnvResult = await resolveInitSyncCreateEnv({
110447
- config: config7,
110448
- env: env2,
109779
+ config: normalizedConfig,
109780
+ env: syncEnv,
110449
109781
  exitOnComplete
110450
109782
  });
110451
109783
  if (!createEnvResult.success) {
@@ -110471,35 +109803,32 @@ async function createConfig(options = {}) {
110471
109803
  return;
110472
109804
  }
110473
109805
  }
110474
- const syncSuccess = await syncCommand({
109806
+ const pushSuccess = await pushCommand({
110475
109807
  exitOnComplete: false,
110476
109808
  skipIntro: true,
110477
- preloadedConfig: { config: config7, configPath },
109809
+ preloadedConfig: { config: normalizedConfig, configPath },
110478
109810
  createEnv: createEnvResult.createEnv,
110479
- yes
109811
+ yes,
109812
+ initMode: true
110480
109813
  });
110481
- if (format) {
110482
- await formatWithPrettier({ cwd, filePath: configPath, logger: M2, silent: true });
109814
+ if (format && existsSync4(configPath)) {
109815
+ await formatWithPrettier({ cwd, filePath: configPath, silent: true });
110483
109816
  }
110484
109817
  if (exitOnComplete)
110485
- process.exit(syncSuccess ? 0 : 1);
109818
+ process.exit(pushSuccess ? 0 : 1);
110486
109819
  }
110487
109820
 
110488
109821
  // src/commands/init/index.ts
110489
109822
  function registerInitCommand(program2) {
110490
- program2.command("init").description("Create a new timeback.config.json file").option("-c, --config <path>", "Path to the config file (default: timeback.config.json)").option("--env <environment>", "Target environment (staging, production)").option("-y, --yes", "Skip confirmations and use defaults where possible").option("--no-format", "Do not run prettier on the generated config file").option("--no-sync", "Skip syncing with Timeback after init").action((opts) => createConfig({
109823
+ program2.command("init").description("Create a new timeback.config.json file").option("-c, --config <path>", "Path to the config file (default: timeback.config.json)").option("--syncEnv <environment>", "Target environment for sync (staging, production)").option("-y, --yes", "Skip confirmations and use defaults where possible").option("--no-format", "Do not run prettier on the generated config file").option("--sync", "Sync with Timeback after creating the config").action((opts) => createConfig({
110491
109824
  configPath: opts.config,
110492
- env: opts.env,
109825
+ syncEnv: opts.syncEnv,
110493
109826
  yes: opts.yes,
110494
109827
  format: opts.format,
110495
- noSync: opts.sync === false
109828
+ sync: opts.sync === true
110496
109829
  }));
110497
109830
  }
110498
109831
 
110499
- // ../internal/utils/src/shared/format.ts
110500
- function pluralize(count, singular, plural) {
110501
- return count === 1 ? singular : plural ?? `${singular}s`;
110502
- }
110503
109832
  // src/commands/inspect/lib/format.ts
110504
109833
  function formatStat(label, value, labelWidth = 24) {
110505
109834
  const formattedValue = typeof value === "number" ? value.toLocaleString() : value;
@@ -110894,6 +110223,594 @@ function registerInspectCommand(program2) {
110894
110223
  });
110895
110224
  }
110896
110225
 
110226
+ // src/commands/resources/pull/lib/apply.ts
110227
+ function applyComparison(config7, comparison) {
110228
+ const course = config7.courses[comparison.index];
110229
+ if (!course)
110230
+ return;
110231
+ for (const change of comparison.changes) {
110232
+ switch (change.field) {
110233
+ case "subject":
110234
+ course.subject = change.remote;
110235
+ break;
110236
+ case "grade":
110237
+ course.grade = change.remote;
110238
+ break;
110239
+ case "courseCode":
110240
+ course.courseCode = change.remote;
110241
+ break;
110242
+ }
110243
+ }
110244
+ }
110245
+ function applyChanges(config7, comparisons) {
110246
+ const updated = {
110247
+ ...config7,
110248
+ courses: config7.courses.map((c) => ({ ...c }))
110249
+ };
110250
+ for (const comparison of comparisons) {
110251
+ applyComparison(updated, comparison);
110252
+ }
110253
+ return updated;
110254
+ }
110255
+ // src/commands/resources/pull/lib/compare.ts
110256
+ function compareCourse(local, remote) {
110257
+ const changes = [];
110258
+ const remoteSubject = remote.subjects?.[0];
110259
+ if (local.subject !== remoteSubject) {
110260
+ changes.push({ field: "subject", local: local.subject, remote: remoteSubject });
110261
+ }
110262
+ const remoteGrade = remote.grades?.[0];
110263
+ if (local.grade !== remoteGrade) {
110264
+ changes.push({ field: "grade", local: local.grade, remote: remoteGrade });
110265
+ }
110266
+ if (local.courseCode !== remote.courseCode) {
110267
+ changes.push({ field: "courseCode", local: local.courseCode, remote: remote.courseCode });
110268
+ }
110269
+ return changes;
110270
+ }
110271
+ function findLinkedCourses(courses) {
110272
+ const linked = [];
110273
+ for (let i = 0;i < courses.length; i++) {
110274
+ const course = courses[i];
110275
+ const ids = course.ids;
110276
+ if (ids?.staging) {
110277
+ linked.push({ index: i, course, env: "staging", courseId: ids.staging });
110278
+ }
110279
+ if (ids?.production) {
110280
+ linked.push({ index: i, course, env: "production", courseId: ids.production });
110281
+ }
110282
+ }
110283
+ return linked;
110284
+ }
110285
+ function filterChanges(comparisons) {
110286
+ return comparisons.filter((c) => c.changes.length > 0);
110287
+ }
110288
+ async function fetchAndCompare(client, linkedCourses, callbacks) {
110289
+ const comparisons = [];
110290
+ for (const linked of linkedCourses) {
110291
+ callbacks.onProgress(linked.course);
110292
+ try {
110293
+ const remote = await client.oneroster.courses.get(linked.courseId);
110294
+ const changes = compareCourse(linked.course, remote);
110295
+ comparisons.push({
110296
+ index: linked.index,
110297
+ course: linked.course,
110298
+ env: linked.env,
110299
+ courseId: linked.courseId,
110300
+ changes
110301
+ });
110302
+ } catch (error57) {
110303
+ const msg = error57 instanceof Error ? error57.message : String(error57);
110304
+ callbacks.onError(linked.course, msg);
110305
+ }
110306
+ }
110307
+ return comparisons;
110308
+ }
110309
+ // src/commands/resources/pull/lib/display.ts
110310
+ function formatCourse2(course) {
110311
+ if (course.grade !== undefined) {
110312
+ return `${course.subject} Grade ${course.grade}`;
110313
+ }
110314
+ if (course.courseCode) {
110315
+ return `${course.subject} (${course.courseCode})`;
110316
+ }
110317
+ return course.subject;
110318
+ }
110319
+ function formatValue6(value) {
110320
+ if (value === undefined)
110321
+ return dim("(not set)");
110322
+ if (value === null)
110323
+ return dim("(null)");
110324
+ return String(value);
110325
+ }
110326
+ function displayChange(change) {
110327
+ M2.info(` ${bold(change.field)}:`);
110328
+ M2.info(` ${red("-")} ${formatValue6(change.local)}`);
110329
+ M2.info(` ${green("+")} ${formatValue6(change.remote)}`);
110330
+ }
110331
+ function displayChanges(comparisons) {
110332
+ M2.info(`
110333
+ ${yellow("Changes to pull:")}
110334
+ `);
110335
+ for (const comparison of comparisons) {
110336
+ const title = formatCourse2(comparison.course);
110337
+ M2.info(` ${bold(title)} ${dim(`(${comparison.env})`)}`);
110338
+ for (const change of comparison.changes) {
110339
+ displayChange(change);
110340
+ }
110341
+ M2.info("");
110342
+ }
110343
+ }
110344
+ // src/commands/resources/pull/pull.ts
110345
+ function getExistingCourseIds(config7) {
110346
+ const byEnv = {};
110347
+ for (const course of config7.courses) {
110348
+ for (const [env2, id] of Object.entries(course.ids ?? {})) {
110349
+ if (!byEnv[env2])
110350
+ byEnv[env2] = [];
110351
+ if (id)
110352
+ byEnv[env2].push(id);
110353
+ }
110354
+ }
110355
+ return byEnv;
110356
+ }
110357
+ async function importMoreCourses(options) {
110358
+ const result = await runImportFlow({
110359
+ configPath: options.configPath,
110360
+ appName: options.config.name,
110361
+ existingCourseIds: getExistingCourseIds(options.config)
110362
+ });
110363
+ if (!result.success) {
110364
+ return {
110365
+ success: false,
110366
+ cancelled: result.cancelled,
110367
+ error: result.error ?? "Import failed"
110368
+ };
110369
+ }
110370
+ return { success: true };
110371
+ }
110372
+ async function handleNoLinkedCourses(options) {
110373
+ const { env: env2, importAfterPull, config: config7, customConfigPath, exitOnComplete } = options;
110374
+ if (!importAfterPull) {
110375
+ const message = [
110376
+ `No courses linked to ${env2}.`,
110377
+ `Run ${bold(greenBright("timeback resources push"))} first.`
110378
+ ].join(`
110379
+ `);
110380
+ Me(message, "No Linked Courses");
110381
+ outro.cancelled();
110382
+ if (exitOnComplete) {
110383
+ process.exit(0);
110384
+ }
110385
+ return true;
110386
+ }
110387
+ const importResult = await importMoreCourses({ config: config7, configPath: customConfigPath });
110388
+ if (!importResult.success) {
110389
+ if (importResult.cancelled) {
110390
+ outro.cancelled();
110391
+ if (exitOnComplete) {
110392
+ process.exit(0);
110393
+ }
110394
+ return true;
110395
+ }
110396
+ M2.error(importResult.error);
110397
+ outro.error("Import failed");
110398
+ if (exitOnComplete) {
110399
+ process.exit(1);
110400
+ }
110401
+ return true;
110402
+ }
110403
+ outro.success();
110404
+ if (exitOnComplete) {
110405
+ process.exit(0);
110406
+ }
110407
+ return true;
110408
+ }
110409
+ async function pullCommand(options) {
110410
+ const {
110411
+ configPath: customConfigPath,
110412
+ env: envOption,
110413
+ apply = false,
110414
+ import: importAfterPull = false,
110415
+ exitOnComplete = true,
110416
+ skipIntro = false
110417
+ } = options;
110418
+ if (!skipIntro) {
110419
+ intro("Timeback Pull");
110420
+ }
110421
+ const configResult = await loadConfig({ configPath: customConfigPath });
110422
+ if (!configResult.success) {
110423
+ M2.error(configResult.error);
110424
+ outro.error("Pull failed");
110425
+ if (exitOnComplete) {
110426
+ process.exit(1);
110427
+ }
110428
+ return;
110429
+ }
110430
+ const { config: config7, configPath } = configResult;
110431
+ const relativeConfigPath = getRelativeConfigPath(configPath);
110432
+ M2.info(`Loaded ${dim(relativeConfigPath)}`);
110433
+ const envResult = await resolveEnvironment({ env: envOption, skipIntro: true });
110434
+ if (!envResult) {
110435
+ outro.cancelled();
110436
+ if (exitOnComplete) {
110437
+ process.exit(0);
110438
+ }
110439
+ return;
110440
+ }
110441
+ const env2 = envResult.env;
110442
+ const linkedCourses = findLinkedCourses(config7.courses).filter((c) => c.env === env2);
110443
+ if (linkedCourses.length === 0) {
110444
+ await handleNoLinkedCourses({
110445
+ env: env2,
110446
+ importAfterPull,
110447
+ config: config7,
110448
+ customConfigPath,
110449
+ exitOnComplete
110450
+ });
110451
+ return;
110452
+ }
110453
+ const authOk = await initAuth({ env: env2 });
110454
+ if (!authOk) {
110455
+ M2.error(`Failed to authenticate for ${env2}`);
110456
+ outro.error("Pull failed");
110457
+ if (exitOnComplete) {
110458
+ process.exit(1);
110459
+ }
110460
+ return;
110461
+ }
110462
+ const client = getAuthContext(env2).timeback;
110463
+ const s = Y2();
110464
+ s.start("Fetching remote course data...");
110465
+ const comparisons = await fetchAndCompare(client, linkedCourses, {
110466
+ onProgress: (course) => s.message(`Checking ${formatCourse2(course)}...`),
110467
+ onError: (course, error57) => M2.warn(`Failed to fetch ${formatCourse2(course)}: ${error57}`)
110468
+ });
110469
+ const withChanges = filterChanges(comparisons);
110470
+ if (withChanges.length === 0) {
110471
+ s.stop("Local config is up to date with remote");
110472
+ if (!importAfterPull) {
110473
+ outro.success();
110474
+ if (exitOnComplete) {
110475
+ process.exit(0);
110476
+ }
110477
+ return;
110478
+ }
110479
+ } else {
110480
+ s.stop("Remote check complete");
110481
+ }
110482
+ if (withChanges.length > 0) {
110483
+ displayChanges(withChanges);
110484
+ const shouldApply = apply || await ye({ message: "Apply these changes to local config?" });
110485
+ if (isCancelled(shouldApply) || !shouldApply) {
110486
+ outro.cancelled();
110487
+ if (exitOnComplete)
110488
+ process.exit(0);
110489
+ return;
110490
+ }
110491
+ const updatedConfig = applyChanges(config7, withChanges);
110492
+ await saveConfig(configPath, updatedConfig);
110493
+ M2.success(`Updated ${dim(relativeConfigPath)}`);
110494
+ M2.success(`Pulled ${bold(withChanges.length)} course${withChanges.length === 1 ? "" : "s"}`);
110495
+ }
110496
+ if (importAfterPull) {
110497
+ const importResult = await importMoreCourses({ config: config7, configPath: customConfigPath });
110498
+ if (!importResult.success) {
110499
+ if (importResult.cancelled) {
110500
+ outro.cancelled();
110501
+ if (exitOnComplete)
110502
+ process.exit(0);
110503
+ return;
110504
+ }
110505
+ M2.error(importResult.error);
110506
+ outro.error("Import failed");
110507
+ if (exitOnComplete)
110508
+ process.exit(1);
110509
+ return;
110510
+ }
110511
+ }
110512
+ outro.success();
110513
+ if (exitOnComplete) {
110514
+ process.exit(0);
110515
+ }
110516
+ }
110517
+
110518
+ // src/commands/resources/pull/index.ts
110519
+ function registerPullSubcommand(resources) {
110520
+ resources.command("pull").description("Pull remote data from Timeback (remote → local)").option("-c, --config <path>", "Path to the config file").option("--env <environment>", "Target environment (staging or production)").option("--apply", "Apply changes without prompting").option("--import", "Also import additional courses (interactive)").action(async (opts) => {
110521
+ await pullCommand({
110522
+ configPath: opts.config,
110523
+ env: opts.env,
110524
+ apply: opts.apply,
110525
+ import: opts.import,
110526
+ skipIntro: true
110527
+ });
110528
+ });
110529
+ }
110530
+
110531
+ // src/commands/resources/push/index.ts
110532
+ function registerPushSubcommand(resources) {
110533
+ resources.command("push").description("Push local data to Timeback (local → remote)").option("-c, --config <path>", "Path to the config file").option("--env <environment>", "Target environment for new courses (staging, production)").option("--dry-run", "Show what would be pushed without making changes").option("-y, --yes", "Skip confirmation prompt").action(async (opts) => {
110534
+ await pushCommand({
110535
+ configPath: opts.config,
110536
+ env: opts.env,
110537
+ dryRun: opts.dryRun,
110538
+ yes: opts.yes,
110539
+ skipIntro: true
110540
+ });
110541
+ });
110542
+ }
110543
+
110544
+ // src/commands/resources/unlink/lib/display.ts
110545
+ function formatCourse3(course) {
110546
+ if (course.grade !== undefined) {
110547
+ return `${course.subject} Grade ${course.grade}`;
110548
+ }
110549
+ if (course.courseCode) {
110550
+ return `${course.subject} (${course.courseCode})`;
110551
+ }
110552
+ return course.subject;
110553
+ }
110554
+ function displayUnlinkSummary(coursesToUnlink) {
110555
+ for (const course of coursesToUnlink) {
110556
+ const lines = [];
110557
+ const courseCode = course.course.courseCode ? ` (${course.course.courseCode})` : "";
110558
+ lines.push(`${bold(course.title)}${dim(courseCode)}`);
110559
+ lines.push("");
110560
+ lines.push(` ${red("- unlink")} ${dim(course.courseId)}`);
110561
+ M2.message(lines.join(`
110562
+ `), { symbol: red("●") });
110563
+ }
110564
+ }
110565
+
110566
+ // src/commands/resources/unlink/lib/config.ts
110567
+ function updateConfigAfterUnlink(config7, unlinkedIndices, env2) {
110568
+ const updatedConfig = { ...config7, courses: [...config7.courses] };
110569
+ for (const index of unlinkedIndices) {
110570
+ const course = { ...updatedConfig.courses[index] };
110571
+ const ids = { ...course.ids };
110572
+ delete ids[env2];
110573
+ if (Object.keys(ids).length === 0) {
110574
+ course.ids = null;
110575
+ } else {
110576
+ course.ids = ids;
110577
+ }
110578
+ updatedConfig.courses[index] = course;
110579
+ }
110580
+ return updatedConfig;
110581
+ }
110582
+ function findLinkedCourses2(config7, env2) {
110583
+ const courses = [];
110584
+ for (let i = 0;i < config7.courses.length; i++) {
110585
+ const course = config7.courses[i];
110586
+ const courseId = course.ids?.[env2];
110587
+ if (courseId) {
110588
+ courses.push({
110589
+ index: i,
110590
+ course,
110591
+ courseId,
110592
+ title: formatCourse3(course)
110593
+ });
110594
+ }
110595
+ }
110596
+ return courses;
110597
+ }
110598
+ // src/commands/resources/unlink/lib/confirm.ts
110599
+ async function confirmUnlink(env2, appName, courseCount) {
110600
+ if (env2 === "production") {
110601
+ const confirmation = await he({
110602
+ message: `Type "${appName}" to confirm unlinking from production:`,
110603
+ placeholder: appName,
110604
+ validate: (value) => {
110605
+ if (value !== appName) {
110606
+ return `Please type "${appName}" exactly to confirm`;
110607
+ }
110608
+ }
110609
+ });
110610
+ if (isCancelled(confirmation) || confirmation !== appName) {
110611
+ return false;
110612
+ }
110613
+ return true;
110614
+ }
110615
+ const shouldUnlink = await ye({
110616
+ message: `Unlink ${courseCount} course${courseCount === 1 ? "" : "s"} from ${env2}?`,
110617
+ initialValue: false
110618
+ });
110619
+ return !isCancelled(shouldUnlink) && shouldUnlink;
110620
+ }
110621
+ async function selectCoursesToUnlink(linkedCourses, all, env2) {
110622
+ if (all)
110623
+ return linkedCourses;
110624
+ const selected = await fe({
110625
+ message: `Select courses to unlink from ${bold(env2)}:`,
110626
+ options: linkedCourses.map((c) => ({
110627
+ value: c.courseId,
110628
+ label: c.title,
110629
+ hint: dim(c.courseId)
110630
+ })),
110631
+ required: true
110632
+ });
110633
+ if (isCancelled(selected))
110634
+ return;
110635
+ return linkedCourses.filter((c) => selected.includes(c.courseId));
110636
+ }
110637
+ // src/commands/resources/unlink/lib/delete.ts
110638
+ async function safeDelete(deleteFn) {
110639
+ try {
110640
+ await deleteFn();
110641
+ return { success: true, notFound: false };
110642
+ } catch (error57) {
110643
+ if (isApiError2(error57) && error57.statusCode === 404) {
110644
+ return { success: true, notFound: true };
110645
+ }
110646
+ throw error57;
110647
+ }
110648
+ }
110649
+ async function deleteCourseStructure(client, courseId, onProgress) {
110650
+ const ids = deriveCourseStructureIds(courseId);
110651
+ let resourcesDeleted = 0;
110652
+ onProgress?.(`Unlinking componentResource ${dim(ids.componentResource)}...`);
110653
+ const crResult = await safeDelete(() => client.oneroster.courses.deleteComponentResource(ids.componentResource));
110654
+ if (crResult.success && !crResult.notFound)
110655
+ resourcesDeleted++;
110656
+ onProgress?.(`Unlinking resource ${dim(ids.resource)}...`);
110657
+ const resourceResult = await safeDelete(() => client.oneroster.resources.delete(ids.resource));
110658
+ if (resourceResult.success && !resourceResult.notFound)
110659
+ resourcesDeleted++;
110660
+ onProgress?.(`Unlinking component ${dim(ids.component)}...`);
110661
+ const componentResult = await safeDelete(() => client.oneroster.courses.deleteComponent(ids.component));
110662
+ if (componentResult.success && !componentResult.notFound)
110663
+ resourcesDeleted++;
110664
+ onProgress?.(`Unlinking course ${dim(ids.course)}...`);
110665
+ const courseResult = await safeDelete(() => client.oneroster.courses.delete(ids.course));
110666
+ if (courseResult.success && !courseResult.notFound)
110667
+ resourcesDeleted++;
110668
+ return resourcesDeleted;
110669
+ }
110670
+ async function executeUnlink(client, courses) {
110671
+ const s = Y2();
110672
+ s.start("Unlinking resources...");
110673
+ let unlinked = 0;
110674
+ let resourcesDeleted = 0;
110675
+ const errors3 = [];
110676
+ const unlinkedIndices = [];
110677
+ for (const course of courses) {
110678
+ try {
110679
+ const count = await deleteCourseStructure(client, course.courseId, (msg) => s.message(msg));
110680
+ resourcesDeleted += count;
110681
+ unlinked++;
110682
+ unlinkedIndices.push(course.index);
110683
+ } catch (error57) {
110684
+ const msg = error57 instanceof Error ? error57.message : String(error57);
110685
+ errors3.push(`Failed to unlink ${course.title}: ${msg}`);
110686
+ }
110687
+ }
110688
+ s.stop("Unlink complete");
110689
+ return { unlinked, resourcesDeleted, errors: errors3, unlinkedIndices };
110690
+ }
110691
+ // src/commands/resources/unlink/unlink.ts
110692
+ async function unlinkCommand(options) {
110693
+ const {
110694
+ configPath: customConfigPath,
110695
+ env: envOption,
110696
+ all = false,
110697
+ dryRun = false,
110698
+ yes = false,
110699
+ exitOnComplete = true,
110700
+ skipIntro = false
110701
+ } = options;
110702
+ if (!skipIntro) {
110703
+ intro("Timeback Unlink");
110704
+ }
110705
+ const configResult = await loadConfig({ configPath: customConfigPath });
110706
+ if (!configResult.success) {
110707
+ M2.error(configResult.error);
110708
+ outro.error("Unlink failed");
110709
+ if (exitOnComplete)
110710
+ process.exit(1);
110711
+ return;
110712
+ }
110713
+ const { config: config7, configPath } = configResult;
110714
+ M2.info(`Loaded ${dim(getRelativeConfigPath(configPath))}`);
110715
+ if (!envOption && yes) {
110716
+ M2.error("--yes requires explicit --env flag");
110717
+ outro.error("Unlink failed");
110718
+ if (exitOnComplete)
110719
+ process.exit(1);
110720
+ return;
110721
+ }
110722
+ const envResult = await resolveEnvironment({ env: envOption, skipIntro: true });
110723
+ if (!envResult) {
110724
+ outro.cancelled();
110725
+ if (exitOnComplete)
110726
+ process.exit(0);
110727
+ return;
110728
+ }
110729
+ const env2 = envResult.env;
110730
+ const linkedCourses = findLinkedCourses2(config7, env2);
110731
+ if (linkedCourses.length === 0) {
110732
+ M2.warn(`No courses linked to ${env2}. Nothing to unlink.`);
110733
+ outro.cancelled();
110734
+ if (exitOnComplete)
110735
+ process.exit(0);
110736
+ return;
110737
+ }
110738
+ const coursesToUnlink = await selectCoursesToUnlink(linkedCourses, all, env2);
110739
+ if (!coursesToUnlink || coursesToUnlink.length === 0) {
110740
+ outro.cancelled();
110741
+ if (exitOnComplete)
110742
+ process.exit(0);
110743
+ return;
110744
+ }
110745
+ displayUnlinkSummary(coursesToUnlink);
110746
+ if (dryRun) {
110747
+ outro.warn("Dry run (no changes made)");
110748
+ if (exitOnComplete)
110749
+ process.exit(0);
110750
+ return;
110751
+ }
110752
+ if (!yes && !await confirmUnlink(env2, config7.name, coursesToUnlink.length)) {
110753
+ outro.cancelled();
110754
+ if (exitOnComplete)
110755
+ process.exit(0);
110756
+ return;
110757
+ }
110758
+ const authOk = await initAuth({ env: env2 });
110759
+ if (!authOk) {
110760
+ M2.error(`Failed to authenticate for ${env2}`);
110761
+ outro.error("Unlink failed");
110762
+ if (exitOnComplete)
110763
+ process.exit(1);
110764
+ return;
110765
+ }
110766
+ const result = await executeUnlink(getAuthContext(env2).timeback, coursesToUnlink);
110767
+ if (result.unlinkedIndices.length > 0) {
110768
+ const updatedConfig = updateConfigAfterUnlink(config7, result.unlinkedIndices, env2);
110769
+ await saveConfig(configPath, updatedConfig);
110770
+ M2.success(`Updated ${dim(getRelativeConfigPath(configPath))}`);
110771
+ }
110772
+ if (result.unlinked > 0) {
110773
+ M2.success(`Unlinked ${bold(result.unlinked)} course${result.unlinked === 1 ? "" : "s"} (${result.resourcesDeleted} resources)`);
110774
+ }
110775
+ for (const error57 of result.errors) {
110776
+ M2.error(error57);
110777
+ }
110778
+ if (result.errors.length > 0) {
110779
+ outro.warn("Completed with errors");
110780
+ if (exitOnComplete)
110781
+ process.exit(1);
110782
+ return;
110783
+ }
110784
+ outro.success();
110785
+ if (exitOnComplete)
110786
+ process.exit(0);
110787
+ }
110788
+
110789
+ // src/commands/resources/unlink/index.ts
110790
+ function registerUnlinkSubcommand(resources) {
110791
+ resources.command("unlink").description("Unlink courses from Timeback (deletes remote, keeps local definition)").option("-c, --config <path>", "Path to the config file").option("--env <environment>", "Target environment (staging or production)").option("--all", "Unlink all linked courses for the environment").option("--dry-run", "Show what would be unlinked without making changes").option("-y, --yes", "Skip confirmation (requires --env)").action(async (opts) => {
110792
+ await unlinkCommand({
110793
+ configPath: opts.config,
110794
+ env: opts.env,
110795
+ all: opts.all,
110796
+ dryRun: opts.dryRun,
110797
+ yes: opts.yes,
110798
+ skipIntro: true
110799
+ });
110800
+ });
110801
+ }
110802
+
110803
+ // src/commands/resources/index.ts
110804
+ function registerResourcesCommand(program2) {
110805
+ const resources = program2.command("resources").description("Manage course resources (push/pull/unlink)");
110806
+ resources.hook("preAction", () => {
110807
+ intro("Timeback Resources");
110808
+ });
110809
+ registerPushSubcommand(resources);
110810
+ registerPullSubcommand(resources);
110811
+ registerUnlinkSubcommand(resources);
110812
+ }
110813
+
110897
110814
  // ../studio/dist/index.js
110898
110815
  import { createRequire as createRequire2 } from "node:module";
110899
110816
  import { stripVTControlCharacters as S22 } from "node:util";
@@ -110903,11 +110820,11 @@ import O2 from "node:readline";
110903
110820
  import { Writable as X2 } from "node:stream";
110904
110821
  import y22 from "node:process";
110905
110822
  import * as tty2 from "tty";
110906
- import { mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile5 } from "node:fs/promises";
110823
+ import { mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
110907
110824
  import { homedir as homedir2, platform as platform22 } from "node:os";
110908
110825
  import { join as join2 } from "node:path";
110909
110826
  import { readFile as readFile22 } from "node:fs/promises";
110910
- import { basename as basename3, extname as extname2, relative as relative2, resolve as resolve6 } from "node:path";
110827
+ import { basename as basename3, extname as extname2, relative as relative2, resolve as resolve5 } from "node:path";
110911
110828
  import { loadConfig as c12LoadConfig2 } from "c12";
110912
110829
  import { basename as basename22 } from "node:path";
110913
110830
  import { createServer as createServerHTTP } from "http";
@@ -125497,7 +125414,7 @@ async function readCredentialsStore2() {
125497
125414
  async function writeCredentialsStore2(store) {
125498
125415
  if (!await ensureCredentialsDir2())
125499
125416
  return false;
125500
- await writeFile5(CREDENTIALS_FILE2, JSON.stringify(store, null, 2), { mode: 384 });
125417
+ await writeFile3(CREDENTIALS_FILE2, JSON.stringify(store, null, 2), { mode: 384 });
125501
125418
  return true;
125502
125419
  }
125503
125420
  async function getSavedCredentials2(environment) {
@@ -125969,14 +125886,20 @@ var TimebackConfig8 = exports_external6.object({
125969
125886
  message: "Duplicate courseCode found; each must be unique",
125970
125887
  path: ["courses"]
125971
125888
  }).refine((config23) => {
125972
- return config23.courses.every((c) => c.sensor !== undefined || config23.sensor !== undefined);
125973
- }, {
125974
- message: "Each course must have an effective sensor; set a top-level `sensor` or per-course `sensor`",
125975
- path: ["courses"]
125976
- }).refine((config23) => {
125977
- return config23.courses.every((c) => c.launchUrl !== undefined || config23.launchUrl !== undefined);
125889
+ return config23.courses.every((c) => {
125890
+ if (c.sensor !== undefined || config23.sensor !== undefined) {
125891
+ return true;
125892
+ }
125893
+ const launchUrls = [
125894
+ c.launchUrl,
125895
+ config23.launchUrl,
125896
+ c.overrides?.staging?.launchUrl,
125897
+ c.overrides?.production?.launchUrl
125898
+ ].filter(Boolean);
125899
+ return launchUrls.length > 0;
125900
+ });
125978
125901
  }, {
125979
- message: "Each course must have an effective launchUrl; set a top-level `launchUrl` or per-course `launchUrl`",
125902
+ message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
125980
125903
  path: ["courses"]
125981
125904
  });
125982
125905
  var EdubridgeDateString8 = exports_external6.union([IsoDateString8, IsoDateTimeString8]);
@@ -126972,7 +126895,7 @@ function deriveCourseIds(config32) {
126972
126895
  }
126973
126896
  async function readPackageVersion(cwd) {
126974
126897
  try {
126975
- const pkgPath = resolve6(cwd, "package.json");
126898
+ const pkgPath = resolve5(cwd, "package.json");
126976
126899
  const pkg = JSON.parse(await readFile22(pkgPath, "utf-8"));
126977
126900
  return pkg.version ?? "0.0.0";
126978
126901
  } catch {
@@ -127006,7 +126929,7 @@ async function loadWithC122(cwd, configPath) {
127006
126929
  const { $schema: _schema, ...configWithoutSchema } = rawConfig;
127007
126930
  return {
127008
126931
  config: configWithoutSchema,
127009
- configFile: result.configFile ?? resolve6(cwd, configPath ?? CONFIG_FILENAME3)
126932
+ configFile: result.configFile ?? resolve5(cwd, configPath ?? CONFIG_FILENAME3)
127010
126933
  };
127011
126934
  }
127012
126935
  async function parse62() {
@@ -127059,7 +126982,6 @@ function printError2(error483) {
127059
126982
  console.log(` ${yellow2(' "$schema": "https://timeback.dev/schema.json",')}`);
127060
126983
  console.log(` ${yellow2(' "name": "My Timeback App",')}`);
127061
126984
  console.log(` ${yellow2(' "launchUrl": "https://example.com/play",')}`);
127062
- console.log(` ${yellow2(' "sensor": "https://example.com/sensor",')}`);
127063
126985
  console.log(` ${yellow2(' "courses": [')}`);
127064
126986
  console.log(` ${yellow2(' { "subject": "Math", "grade": 3 }')}`);
127065
126987
  console.log(` ${yellow2(" ]")}`);
@@ -127110,6 +127032,46 @@ function toCourseConfig2(course, env22) {
127110
127032
  }
127111
127033
  return config32;
127112
127034
  }
127035
+ async function inferLaunchUrl2(client, courseId) {
127036
+ try {
127037
+ const scopedCourse = client.oneroster.courses(courseId);
127038
+ const components = await scopedCourse.components({ where: { status: "active" } });
127039
+ const componentResourceIds = [];
127040
+ for (const component of components) {
127041
+ if (!component.sourcedId)
127042
+ continue;
127043
+ const crs = await client.oneroster.courses.componentResources({
127044
+ where: { "courseComponent.sourcedId": component.sourcedId, status: "active" }
127045
+ });
127046
+ for (const cr of crs) {
127047
+ if (cr.resource?.sourcedId) {
127048
+ componentResourceIds.push(cr.resource.sourcedId);
127049
+ }
127050
+ }
127051
+ }
127052
+ if (componentResourceIds.length === 0) {
127053
+ return;
127054
+ }
127055
+ const resourceIds = [...new Set(componentResourceIds)];
127056
+ const resources = await client.oneroster.resources.listAll({
127057
+ where: { sourcedId: { in: resourceIds } }
127058
+ });
127059
+ const launchUrls = new Set;
127060
+ for (const resource of resources) {
127061
+ const metadata = resource.metadata;
127062
+ const launchUrl = metadata?.launchUrl;
127063
+ if (typeof launchUrl === "string" && launchUrl.length > 0) {
127064
+ launchUrls.add(launchUrl);
127065
+ }
127066
+ }
127067
+ if (launchUrls.size === 1) {
127068
+ return [...launchUrls][0];
127069
+ }
127070
+ return;
127071
+ } catch {
127072
+ return;
127073
+ }
127074
+ }
127113
127075
  function getParser2(opts = {}) {
127114
127076
  return opts.playcademy ? playcademyParser2 : timebackParser2;
127115
127077
  }
@@ -127134,11 +127096,11 @@ async function promptSelectEnv2(configuredEnvs) {
127134
127096
  }
127135
127097
  async function promptAppName3() {
127136
127098
  const name = await he2({
127137
- message: "App name",
127099
+ message: "Search for your app",
127138
127100
  placeholder: "My Timeback App",
127139
127101
  validate: (value) => {
127140
127102
  if (!value.trim())
127141
- return "App name is required";
127103
+ return "Please enter a search term";
127142
127104
  }
127143
127105
  });
127144
127106
  if (pD2(name))
@@ -127158,7 +127120,7 @@ async function promptSearchQuery2() {
127158
127120
  return;
127159
127121
  return query;
127160
127122
  }
127161
- function formatCourseLabel3(course) {
127123
+ function formatCourseLabel2(course) {
127162
127124
  return course.title ?? "Untitled";
127163
127125
  }
127164
127126
  function sortCoursesByGrade2(courses) {
@@ -127174,7 +127136,7 @@ async function promptSelectCourses2(courses) {
127174
127136
  message: `Which courses would you like to import?`,
127175
127137
  options: sorted.map((c) => ({
127176
127138
  value: c,
127177
- label: formatCourseLabel3(c)
127139
+ label: formatCourseLabel2(c)
127178
127140
  })),
127179
127141
  required: true
127180
127142
  });
@@ -127243,6 +127205,40 @@ async function selectCourses2(client, initialResults, filterFn) {
127243
127205
  results = filter(moreResults);
127244
127206
  }
127245
127207
  }
127208
+ async function enrichCoursesWithLaunchUrls2(client, courses) {
127209
+ if (courses.length === 0)
127210
+ return courses;
127211
+ const s = Y22();
127212
+ let didStop = false;
127213
+ const stopOnce = (message) => {
127214
+ if (didStop)
127215
+ return;
127216
+ s.stop(message);
127217
+ didStop = true;
127218
+ };
127219
+ s.start("Fetching course details...");
127220
+ const enriched = [];
127221
+ try {
127222
+ for (const course of courses) {
127223
+ const courseId = course.ids?.staging ?? course.ids?.production;
127224
+ if (!courseId) {
127225
+ enriched.push(course);
127226
+ continue;
127227
+ }
127228
+ const launchUrl = await inferLaunchUrl2(client, courseId);
127229
+ if (launchUrl && !course.launchUrl) {
127230
+ enriched.push({ ...course, launchUrl });
127231
+ } else {
127232
+ enriched.push(course);
127233
+ }
127234
+ }
127235
+ stopOnce("Course details loaded");
127236
+ return enriched;
127237
+ } catch (error483) {
127238
+ stopOnce("Failed to load course details");
127239
+ throw error483;
127240
+ }
127241
+ }
127246
127242
  async function searchAndSelectCourses3(options) {
127247
127243
  const { client, environment, appName: existingAppName, excludeCourseIds = [] } = options;
127248
127244
  const filterExcluded = (courses) => {
@@ -127265,11 +127261,11 @@ async function searchAndSelectCourses3(options) {
127265
127261
  return { success: false, cancelled: true };
127266
127262
  }
127267
127263
  const courseConfigs = courses.map((c) => toCourseConfig2(c, environment));
127268
- M22.success(`Selected ${courseConfigs.length} course${courseConfigs.length === 1 ? "" : "s"}`);
127264
+ const enrichedConfigs = await enrichCoursesWithLaunchUrls2(client, courseConfigs);
127269
127265
  return {
127270
127266
  success: true,
127271
127267
  appName,
127272
- courses: courseConfigs,
127268
+ courses: enrichedConfigs,
127273
127269
  environment
127274
127270
  };
127275
127271
  } catch (error483) {
@@ -130262,9 +130258,9 @@ var LEVEL_PREFIX9 = {
130262
130258
  error: "[ERROR]"
130263
130259
  };
130264
130260
  function formatContext26(context) {
130265
- return Object.entries(context).map(([key, value]) => `${key}=${formatValue6(value)}`).join(" ");
130261
+ return Object.entries(context).map(([key, value]) => `${key}=${formatValue10(value)}`).join(" ");
130266
130262
  }
130267
- function formatValue6(value) {
130263
+ function formatValue10(value) {
130268
130264
  if (typeof value === "string")
130269
130265
  return value;
130270
130266
  if (typeof value === "number")
@@ -131598,26 +131594,412 @@ function registerStudioCommand(program2) {
131598
131594
  });
131599
131595
  }
131600
131596
 
131601
- // src/commands/sync/index.ts
131602
- function registerSyncCommand(program2) {
131603
- program2.command("sync").description("Sync local config with Timeback").option("-c, --config <path>", "Path to the config file").option("--env <environment>", "Target environment (staging, production)").option("--dry-run", "Show what would be synced without making changes").action(async (opts) => {
131604
- await syncCommand({
131605
- configPath: opts.config,
131606
- env: opts.env,
131607
- dryRun: opts.dryRun
131608
- });
131597
+ // src/commands/upgrade/upgrade.ts
131598
+ import { execSync as execSync2 } from "node:child_process";
131599
+
131600
+ // ../internal/utils/src/server/spinner.ts
131601
+ import { stdout as stdout2 } from "node:process";
131602
+
131603
+ // ../internal/utils/src/server/terminal.ts
131604
+ import { stdout } from "node:process";
131605
+ var isInteractive2 = stdout.isTTY ?? false;
131606
+ var cursor = {
131607
+ hide: "\x1B[?25l",
131608
+ show: "\x1B[?25h",
131609
+ up: (n) => `\x1B[${n}A`,
131610
+ down: (n) => `\x1B[${n}B`,
131611
+ forward: (n) => `\x1B[${n}C`,
131612
+ back: (n) => `\x1B[${n}D`,
131613
+ clearLine: "\x1B[K",
131614
+ clearScreen: "\x1B[2J",
131615
+ home: "\x1B[H"
131616
+ };
131617
+ function stripAnsi(str) {
131618
+ return str.replaceAll(/\u001B\[[0-9;]*[a-zA-Z]/g, "");
131619
+ }
131620
+
131621
+ // ../internal/utils/src/server/spinner.ts
131622
+ var SPINNER_FRAMES = [
131623
+ 10251,
131624
+ 10265,
131625
+ 10297,
131626
+ 10296,
131627
+ 10300,
131628
+ 10292,
131629
+ 10278,
131630
+ 10279,
131631
+ 10247,
131632
+ 10255
131633
+ ].map((code) => String.fromCodePoint(code));
131634
+ var SPINNER_INTERVAL = 80;
131635
+ var CHECK_MARK = "✔";
131636
+ var CROSS_MARK = "✖";
131637
+ var STATUS_LABELS = {
131638
+ pending: "[PENDING]",
131639
+ running: "[RUNNING]",
131640
+ success: "[SUCCESS]",
131641
+ error: "[ERROR]"
131642
+ };
131643
+
131644
+ class Spinner {
131645
+ tasks = new Map;
131646
+ frameIndex = 0;
131647
+ intervalId = null;
131648
+ previousLineCount = 0;
131649
+ printedTasks = new Set;
131650
+ constructor(taskIds, texts) {
131651
+ for (const [index, id] of taskIds.entries()) {
131652
+ this.tasks.set(id, {
131653
+ text: texts[index] ?? "",
131654
+ status: "pending"
131655
+ });
131656
+ }
131657
+ }
131658
+ start() {
131659
+ if (!isInteractive2)
131660
+ return;
131661
+ stdout2.write(cursor.hide);
131662
+ this.render();
131663
+ this.intervalId = setInterval(() => {
131664
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
131665
+ this.render();
131666
+ }, SPINNER_INTERVAL);
131667
+ }
131668
+ updateTask(taskId, status, finalText) {
131669
+ const task = this.tasks.get(taskId);
131670
+ if (!task)
131671
+ return;
131672
+ task.status = status;
131673
+ if (finalText)
131674
+ task.finalText = finalText;
131675
+ if (!isInteractive2) {
131676
+ this.renderNonInteractive(taskId, task);
131677
+ }
131678
+ }
131679
+ stop() {
131680
+ if (this.intervalId) {
131681
+ clearInterval(this.intervalId);
131682
+ this.intervalId = null;
131683
+ }
131684
+ if (isInteractive2) {
131685
+ this.render();
131686
+ stdout2.write(cursor.show);
131687
+ }
131688
+ }
131689
+ clear() {
131690
+ if (this.intervalId) {
131691
+ clearInterval(this.intervalId);
131692
+ this.intervalId = null;
131693
+ }
131694
+ if (isInteractive2 && this.previousLineCount > 0) {
131695
+ stdout2.write(cursor.up(this.previousLineCount));
131696
+ for (let i = 0;i < this.previousLineCount; i++) {
131697
+ stdout2.write(`\r${cursor.clearLine}
131698
+ `);
131699
+ }
131700
+ stdout2.write(cursor.up(this.previousLineCount));
131701
+ stdout2.write(cursor.show);
131702
+ }
131703
+ this.previousLineCount = 0;
131704
+ }
131705
+ render() {
131706
+ if (this.previousLineCount > 0) {
131707
+ stdout2.write(cursor.up(this.previousLineCount));
131708
+ }
131709
+ const spinner = SPINNER_FRAMES[this.frameIndex];
131710
+ const visibleTasks = [...this.tasks.values()].filter((t) => t.status !== "pending");
131711
+ for (const task of visibleTasks) {
131712
+ stdout2.write(`\r${cursor.clearLine}`);
131713
+ console.log(this.formatLine(task, spinner));
131714
+ }
131715
+ this.previousLineCount = visibleTasks.length;
131716
+ }
131717
+ formatLine(task, spinner) {
131718
+ switch (task.status) {
131719
+ case "running":
131720
+ return `${blue(spinner ?? "○")} ${task.text}`;
131721
+ case "success":
131722
+ return `${green(CHECK_MARK)} ${task.finalText ?? task.text}`;
131723
+ case "error":
131724
+ return `${red(CROSS_MARK)} Failed: ${task.text}`;
131725
+ default:
131726
+ return task.text;
131727
+ }
131728
+ }
131729
+ renderNonInteractive(taskId, task) {
131730
+ const key = `${taskId}-${task.status}`;
131731
+ if (this.printedTasks.has(key))
131732
+ return;
131733
+ this.printedTasks.add(key);
131734
+ const text = task.status === "success" ? task.finalText ?? task.text : task.text;
131735
+ console.log(`${STATUS_LABELS[task.status]} ${stripAnsi(text)}`);
131736
+ }
131737
+ }
131738
+ // ../internal/utils/src/server/runtime.ts
131739
+ import { execSync } from "node:child_process";
131740
+ import { join as join3 } from "node:path";
131741
+ var PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
131742
+ var PACKAGE_NAME = "timeback";
131743
+ function getPlatformInfo() {
131744
+ return {
131745
+ platform: process.platform,
131746
+ arch: process.arch
131747
+ };
131748
+ }
131749
+ var STANDALONE_PATHS = [
131750
+ join3(".timeback", "bin"),
131751
+ join3(".local", "bin"),
131752
+ "/usr/local/bin/timeback",
131753
+ "/opt/homebrew/bin/timeback"
131754
+ ];
131755
+ function isStandaloneBinary() {
131756
+ const execPath = process.execPath.toLowerCase();
131757
+ return STANDALONE_PATHS.some((p3) => execPath.includes(p3.toLowerCase()));
131758
+ }
131759
+ function runCommand(cmd) {
131760
+ try {
131761
+ return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
131762
+ } catch {
131763
+ return "";
131764
+ }
131765
+ }
131766
+ var GLOBAL_LIST_COMMANDS = {
131767
+ npm: "npm list -g --depth=0 2>/dev/null",
131768
+ bun: "bun pm ls -g 2>/dev/null",
131769
+ pnpm: "pnpm list -g --depth=0 2>/dev/null",
131770
+ yarn: "yarn global list 2>/dev/null"
131771
+ };
131772
+ function getInstallMethod() {
131773
+ if (isStandaloneBinary()) {
131774
+ return "standalone";
131775
+ }
131776
+ const execPath = process.execPath.toLowerCase();
131777
+ const sortedManagers = [...PACKAGE_MANAGERS].sort((a, b3) => {
131778
+ const aMatches = execPath.includes(a);
131779
+ const bMatches = execPath.includes(b3);
131780
+ if (aMatches && !bMatches)
131781
+ return -1;
131782
+ if (!aMatches && bMatches)
131783
+ return 1;
131784
+ return 0;
131785
+ });
131786
+ for (const pm of sortedManagers) {
131787
+ const output2 = runCommand(GLOBAL_LIST_COMMANDS[pm]);
131788
+ if (output2.includes(PACKAGE_NAME)) {
131789
+ return pm;
131790
+ }
131791
+ }
131792
+ return "unknown";
131793
+ }
131794
+ // src/commands/upgrade/lib/constants.ts
131795
+ var LATEST_VERSION_URL = "https://dl.timeback.dev/cli/latest/version.txt";
131796
+ var DOWNLOAD_BASE_URL = "https://dl.timeback.dev/cli/latest";
131797
+ var BINARY_NAME = "timeback";
131798
+ var DEFAULT_INSTALL_PATH = "/usr/local/bin/timeback";
131799
+ var BINARY_NAMES = {
131800
+ "darwin-arm64": "timeback-darwin-arm64",
131801
+ "darwin-x64": "timeback-darwin-x64",
131802
+ "linux-arm64": "timeback-linux-arm64",
131803
+ "linux-x64": "timeback-linux-x64",
131804
+ "win32-x64": "timeback-windows-x64.exe"
131805
+ };
131806
+ var PACKAGE_MANAGER_COMMANDS = {
131807
+ bun: "bun update -g timeback",
131808
+ npm: "npm update -g timeback",
131809
+ yarn: "yarn global upgrade timeback",
131810
+ pnpm: "pnpm update -g timeback"
131811
+ };
131812
+ // src/commands/upgrade/lib/platform.ts
131813
+ function getBinaryName() {
131814
+ const { platform: platform4, arch } = getPlatformInfo();
131815
+ const key = `${platform4}-${arch}`;
131816
+ const binaryName = BINARY_NAMES[key];
131817
+ if (!binaryName) {
131818
+ throw new Error(`Unsupported platform: ${platform4} ${arch}`);
131819
+ }
131820
+ return binaryName;
131821
+ }
131822
+ // src/commands/upgrade/lib/download.ts
131823
+ import { createWriteStream, existsSync as existsSync5, renameSync, unlinkSync } from "node:fs";
131824
+ import { chmod } from "node:fs/promises";
131825
+ import { tmpdir } from "node:os";
131826
+ import { dirname, join as join4 } from "node:path";
131827
+ import { Readable as Readable2 } from "node:stream";
131828
+ import { finished } from "node:stream/promises";
131829
+ async function fetchLatestVersion() {
131830
+ const response = await fetch(LATEST_VERSION_URL);
131831
+ if (!response.ok) {
131832
+ throw new Error(`Failed to fetch latest version: ${response.status} ${response.statusText}`);
131833
+ }
131834
+ return (await response.text()).trim();
131835
+ }
131836
+ async function downloadBinary(binaryName, targetPath) {
131837
+ const url7 = `${DOWNLOAD_BASE_URL}/${binaryName}`;
131838
+ const response = await fetch(url7);
131839
+ if (!response.ok) {
131840
+ throw new Error(`Failed to download binary: ${response.status} ${response.statusText}`);
131841
+ }
131842
+ if (!response.body) {
131843
+ throw new Error("No response body");
131844
+ }
131845
+ const fileStream = createWriteStream(targetPath);
131846
+ const readable = Readable2.fromWeb(response.body);
131847
+ await finished(readable.pipe(fileStream));
131848
+ }
131849
+ async function installBinary() {
131850
+ const binaryName = getBinaryName();
131851
+ const tempPath = join4(tmpdir(), `timeback-upgrade-${Date.now()}`);
131852
+ await downloadBinary(binaryName, tempPath);
131853
+ await chmod(tempPath, 493);
131854
+ const currentBinary = process.argv[0] ?? DEFAULT_INSTALL_PATH;
131855
+ const targetDir = dirname(currentBinary);
131856
+ const targetPath = join4(targetDir, BINARY_NAME);
131857
+ const backupPath = `${targetPath}.backup`;
131858
+ if (existsSync5(targetPath)) {
131859
+ renameSync(targetPath, backupPath);
131860
+ }
131861
+ try {
131862
+ renameSync(tempPath, targetPath);
131863
+ if (existsSync5(backupPath)) {
131864
+ unlinkSync(backupPath);
131865
+ }
131866
+ return targetPath;
131867
+ } catch (installError) {
131868
+ if (existsSync5(backupPath)) {
131869
+ renameSync(backupPath, targetPath);
131870
+ }
131871
+ throw installError;
131872
+ }
131873
+ }
131874
+ // src/commands/upgrade/upgrade.ts
131875
+ async function upgradeStandalone(latestVersion) {
131876
+ const s = Y2();
131877
+ s.start("Downloading upgrade...");
131878
+ s.message("Installing upgrade...");
131879
+ await installBinary();
131880
+ s.stop("Upgrade installed");
131881
+ M2.success(`Upgraded to ${bold(latestVersion)}`);
131882
+ }
131883
+ async function upgradeViaPackageManager(method, latestVersion) {
131884
+ const command = PACKAGE_MANAGER_COMMANDS[method];
131885
+ const shouldRun = await ye({ message: "Run this command now?", initialValue: true });
131886
+ if (isCancelled(shouldRun) || !shouldRun) {
131887
+ const message = [`To upgrade via ${method}, run:`, "", ` ${bold(bold(greenBright(command)))}`];
131888
+ Me(message.join(`
131889
+ `), "Manual Upgrade Instructions");
131890
+ return false;
131891
+ }
131892
+ try {
131893
+ execSync2(command, { stdio: "inherit" });
131894
+ M2.success(`Upgraded to ${bold(latestVersion)}`);
131895
+ return true;
131896
+ } catch {
131897
+ M2.error("Upgrade command failed. Please run manually.");
131898
+ return false;
131899
+ }
131900
+ }
131901
+ function showManualUpgradeInstructions() {
131902
+ M2.message(dim("Upgrade with your package manager, e.g. ") + bold("bun update -g timeback"));
131903
+ }
131904
+ async function upgradeCommand(options = {}) {
131905
+ const { exitOnComplete = true } = options;
131906
+ intro("Timeback Upgrade");
131907
+ const s = Y2();
131908
+ s.start("Detecting installation method...");
131909
+ const installMethod = getInstallMethod();
131910
+ const methodLabel = installMethod === "unknown" ? "" : ` ${dim(`(${installMethod})`)}`;
131911
+ s.stop(`Current: ${bold(cliVersion)}${methodLabel}`);
131912
+ s.start("Checking for updates...");
131913
+ let latestVersion;
131914
+ try {
131915
+ latestVersion = await fetchLatestVersion();
131916
+ } catch (error59) {
131917
+ s.stop("Failed to check for updates");
131918
+ M2.error(error59 instanceof Error ? error59.message : String(error59));
131919
+ outro.error("Upgrade failed");
131920
+ if (exitOnComplete) {
131921
+ process.exit(1);
131922
+ }
131923
+ return;
131924
+ }
131925
+ s.stop(`Latest version: ${bold(latestVersion)}`);
131926
+ if (cliVersion === latestVersion || cliVersion === `${latestVersion}-dev`) {
131927
+ M2.success("Already up to date!");
131928
+ outro.success();
131929
+ if (exitOnComplete) {
131930
+ process.exit(0);
131931
+ }
131932
+ return;
131933
+ }
131934
+ M2.info(`${yellow("Upgrade available:")} ${bold(red(cliVersion))} → ${bold(greenBright(latestVersion))}`);
131935
+ try {
131936
+ switch (installMethod) {
131937
+ case "standalone": {
131938
+ const shouldUpgrade = await ye({
131939
+ message: "Download and install the upgrade?",
131940
+ initialValue: true
131941
+ });
131942
+ if (isCancelled(shouldUpgrade) || !shouldUpgrade) {
131943
+ outro.cancelled();
131944
+ if (exitOnComplete) {
131945
+ process.exit(0);
131946
+ }
131947
+ return;
131948
+ }
131949
+ await upgradeStandalone(latestVersion);
131950
+ break;
131951
+ }
131952
+ case "bun":
131953
+ case "npm":
131954
+ case "yarn":
131955
+ case "pnpm": {
131956
+ const success7 = await upgradeViaPackageManager(installMethod, latestVersion);
131957
+ if (!success7) {
131958
+ outro.error("Upgrade incomplete");
131959
+ if (exitOnComplete) {
131960
+ process.exit(1);
131961
+ }
131962
+ return;
131963
+ }
131964
+ break;
131965
+ }
131966
+ default: {
131967
+ showManualUpgradeInstructions();
131968
+ outro.cancelled();
131969
+ if (exitOnComplete) {
131970
+ process.exit(0);
131971
+ }
131972
+ return;
131973
+ }
131974
+ }
131975
+ } catch (error59) {
131976
+ M2.error(error59 instanceof Error ? error59.message : String(error59));
131977
+ outro.error("Upgrade failed");
131978
+ if (exitOnComplete) {
131979
+ process.exit(1);
131980
+ }
131981
+ return;
131982
+ }
131983
+ outro.success();
131984
+ if (exitOnComplete) {
131985
+ process.exit(0);
131986
+ }
131987
+ }
131988
+
131989
+ // src/commands/upgrade/index.ts
131990
+ function registerUpgradeCommand(program2) {
131991
+ program2.command("upgrade").description("Upgrade the Timeback CLI to the latest version").action(async () => {
131992
+ await upgradeCommand({});
131609
131993
  });
131610
131994
  }
131611
131995
 
131612
131996
  // src/cli.ts
131613
- var version8 = "0.1.6";
131614
- program.name("timeback").description("CLI for Timeback tools and integrations").version(version8);
131997
+ program.name("timeback").description("CLI for Timeback tools and integrations").version(cliVersion);
131615
131998
  registerApiCommand(program);
131616
- registerCredentialsCommand(program);
131617
- registerEditCommand(program);
131618
- registerImportCommand(program);
131619
131999
  registerInitCommand(program);
132000
+ registerCredentialsCommand(program);
131620
132001
  registerInspectCommand(program);
132002
+ registerResourcesCommand(program);
131621
132003
  registerStudioCommand(program);
131622
- registerSyncCommand(program);
132004
+ registerUpgradeCommand(program);
131623
132005
  program.parse();