@tomkapa/tayto 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ APP_VERSION,
3
4
  DependencyType,
5
+ GitRemote,
4
6
  RANK_GAP,
5
7
  TERMINAL_STATUSES,
6
8
  TaskLevel,
@@ -11,11 +13,12 @@ import {
11
13
  detectGitRemote,
12
14
  err,
13
15
  getTaskLevel,
16
+ isNewerVersion,
14
17
  isTerminalStatus,
15
18
  logger,
16
19
  midpoint,
17
20
  ok
18
- } from "./chunk-74Q55TOV.js";
21
+ } from "./chunk-XD24XQNF.js";
19
22
 
20
23
  // src/config/index.ts
21
24
  import { mkdirSync } from "fs";
@@ -35,6 +38,8 @@ function loadConfig() {
35
38
  logLevel: process.env["TASK_LOG_LEVEL"] ?? "info",
36
39
  otelEndpoint: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
37
40
  updateCachePath: join(dataDir, "update-check.json"),
41
+ dismissedGitRemotesPath: join(dataDir, "dismissed-git-remotes.json"),
42
+ telemetryStatePath: join(dataDir, "telemetry.json"),
38
43
  noUpdateCheck: process.env["TAYTO_NO_UPDATE_CHECK"] === "1"
39
44
  };
40
45
  }
@@ -160,7 +165,7 @@ function rowToProject(row) {
160
165
  name: row.name,
161
166
  description: row.description,
162
167
  isDefault: row.is_default === 1,
163
- gitRemote: row.git_remote,
168
+ gitRemote: row.git_remote ? GitRemote.parse(row.git_remote) : null,
164
169
  createdAt: row.created_at,
165
170
  updatedAt: row.updated_at
166
171
  };
@@ -187,7 +192,7 @@ var SqliteProjectRepository = class {
187
192
  input.name,
188
193
  input.description ?? "",
189
194
  input.isDefault ? 1 : 0,
190
- input.gitRemote ?? null,
195
+ input.gitRemote?.value ?? null,
191
196
  now,
192
197
  now
193
198
  );
@@ -202,7 +207,7 @@ var SqliteProjectRepository = class {
202
207
  return err(
203
208
  new AppError(
204
209
  "DUPLICATE",
205
- `Git remote already linked to another project: ${input.gitRemote}`,
210
+ `Git remote already linked to another project: ${input.gitRemote?.value}`,
206
211
  e
207
212
  )
208
213
  );
@@ -239,7 +244,7 @@ var SqliteProjectRepository = class {
239
244
  }
240
245
  findByGitRemote(remote) {
241
246
  try {
242
- const row = this.db.prepare(`SELECT * FROM projects WHERE git_remote = ? AND ${NOT_DELETED}`).get(remote);
247
+ const row = this.db.prepare(`SELECT * FROM projects WHERE git_remote = ? AND ${NOT_DELETED}`).get(remote.value);
243
248
  return ok(row ? rowToProject(row) : null);
244
249
  } catch (e) {
245
250
  return err(new AppError("DB_ERROR", "Failed to find project by git remote", e));
@@ -280,7 +285,7 @@ var SqliteProjectRepository = class {
280
285
  input.name ?? existing.name,
281
286
  input.description ?? existing.description,
282
287
  input.isDefault !== void 0 ? input.isDefault ? 1 : 0 : existing.is_default,
283
- input.gitRemote !== void 0 ? input.gitRemote : existing.git_remote,
288
+ input.gitRemote !== void 0 ? input.gitRemote?.value ?? null : existing.git_remote,
284
289
  now,
285
290
  id
286
291
  );
@@ -967,18 +972,19 @@ var SqliteDependencyRepository = class {
967
972
 
968
973
  // src/types/project.ts
969
974
  import { z } from "zod/v4";
975
+ var gitRemoteField = z.string().min(1, "Git remote URL must not be empty").transform((v) => GitRemote.parse(v)).nullable().optional();
970
976
  var CreateProjectSchema = z.object({
971
977
  name: z.string().min(1, "Project name is required").max(255),
972
978
  key: z.string().min(2, "Project key must be at least 2 characters").max(7, "Project key must be at most 7 characters").regex(/^[A-Za-z0-9]+$/, "Project key must contain only letters and digits").transform((v) => v.toUpperCase()).optional(),
973
979
  description: z.string().max(5e3).optional(),
974
980
  isDefault: z.boolean().optional(),
975
- gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
981
+ gitRemote: gitRemoteField
976
982
  });
977
983
  var UpdateProjectSchema = z.object({
978
984
  name: z.string().min(1).max(255).optional(),
979
985
  description: z.string().max(5e3).optional(),
980
986
  isDefault: z.boolean().optional(),
981
- gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
987
+ gitRemote: gitRemoteField
982
988
  });
983
989
 
984
990
  // src/service/project.service.ts
@@ -1083,8 +1089,10 @@ var ProjectServiceImpl = class {
1083
1089
  return logger.startSpan("ProjectService.linkGitRemote", () => {
1084
1090
  const resolved = this.resolveProject(idOrName);
1085
1091
  if (!resolved.ok) return resolved;
1086
- let url = remote;
1087
- if (!url) {
1092
+ let gitRemote;
1093
+ if (remote) {
1094
+ gitRemote = GitRemote.parse(remote);
1095
+ } else {
1088
1096
  const detected = this.detectRemote();
1089
1097
  if (!detected.ok) return detected;
1090
1098
  if (!detected.value) {
@@ -1095,9 +1103,9 @@ var ProjectServiceImpl = class {
1095
1103
  )
1096
1104
  );
1097
1105
  }
1098
- url = detected.value;
1106
+ gitRemote = detected.value;
1099
1107
  }
1100
- return this.repo.update(resolved.value.id, { gitRemote: url });
1108
+ return this.repo.update(resolved.value.id, { gitRemote });
1101
1109
  });
1102
1110
  }
1103
1111
  unlinkGitRemote(idOrName) {
@@ -2018,17 +2026,6 @@ function isValidCache(value) {
2018
2026
  const obj = value;
2019
2027
  return typeof obj["checkedAt"] === "number" && typeof obj["latestVersion"] === "string";
2020
2028
  }
2021
- function isNewerVersion(a, b) {
2022
- const parse = (v) => {
2023
- const parts = v.replace(/^v/, "").split(".").map(Number);
2024
- return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
2025
- };
2026
- const [aMaj, aMin, aPatch] = parse(a);
2027
- const [bMaj, bMin, bPatch] = parse(b);
2028
- if (aMaj !== bMaj) return aMaj > bMaj;
2029
- if (aMin !== bMin) return aMin > bMin;
2030
- return aPatch > bPatch;
2031
- }
2032
2029
  function defaultExec(cmd, args) {
2033
2030
  execFileSync(cmd, args, { stdio: "inherit", timeout: 6e4 });
2034
2031
  }
@@ -2130,7 +2127,16 @@ var UpdateServiceImpl = class {
2130
2127
  }
2131
2128
  writeCache(entry) {
2132
2129
  try {
2133
- writeFileSync(this.cachePath, JSON.stringify(entry), "utf-8");
2130
+ let existing = {};
2131
+ try {
2132
+ const raw = readFileSync2(this.cachePath, "utf-8");
2133
+ const parsed = JSON.parse(raw);
2134
+ if (typeof parsed === "object" && parsed !== null) {
2135
+ existing = parsed;
2136
+ }
2137
+ } catch {
2138
+ }
2139
+ writeFileSync(this.cachePath, JSON.stringify({ ...existing, ...entry }), "utf-8");
2134
2140
  } catch (e) {
2135
2141
  logger.warn("Failed to write update cache", {
2136
2142
  error: toMessage(e)
@@ -2140,7 +2146,7 @@ var UpdateServiceImpl = class {
2140
2146
  };
2141
2147
 
2142
2148
  // src/cli/container.ts
2143
- function createContainer(db, dbPath, detectGitRemote2, updateCachePath) {
2149
+ function createContainer(db, dbPath, detectGitRemote2, updateCachePath, dismissedGitRemotesPath) {
2144
2150
  const projectRepo = new SqliteProjectRepository(db);
2145
2151
  const taskRepo = new SqliteTaskRepository(db);
2146
2152
  const depRepo = new SqliteDependencyRepository(db);
@@ -2148,11 +2154,12 @@ function createContainer(db, dbPath, detectGitRemote2, updateCachePath) {
2148
2154
  const dependencyService = new DependencyServiceImpl(depRepo, taskRepo);
2149
2155
  const taskService = new TaskServiceImpl(taskRepo, projectService, () => dependencyService);
2150
2156
  const portabilityService = new PortabilityServiceImpl(taskService, dependencyService);
2151
- const updateService = new UpdateServiceImpl(
2152
- updateCachePath ?? join3(tmpdir(), "tayto-update-check.json")
2153
- );
2157
+ const resolvedUpdateCachePath = updateCachePath ?? join3(tmpdir(), "tayto-update-check.json");
2158
+ const updateService = new UpdateServiceImpl(resolvedUpdateCachePath);
2154
2159
  return {
2155
2160
  dbPath,
2161
+ updateCachePath: resolvedUpdateCachePath,
2162
+ dismissedGitRemotesPath: dismissedGitRemotesPath ?? join3(tmpdir(), "tayto-dismissed-git-remotes.json"),
2156
2163
  projectService,
2157
2164
  taskService,
2158
2165
  dependencyService,
@@ -2164,9 +2171,6 @@ function createContainer(db, dbPath, detectGitRemote2, updateCachePath) {
2164
2171
  // src/cli/index.ts
2165
2172
  import { Command } from "commander";
2166
2173
 
2167
- // src/version.ts
2168
- var APP_VERSION = true ? "0.6.0" : "0.0.0-dev";
2169
-
2170
2174
  // src/cli/output.ts
2171
2175
  function printSuccess(data) {
2172
2176
  process.stdout.write(JSON.stringify({ ok: true, data }, null, 2) + "\n");
@@ -2214,18 +2218,20 @@ function registerProjectList(parent, container) {
2214
2218
 
2215
2219
  // src/cli/commands/project/update.ts
2216
2220
  function registerProjectUpdate(parent, container) {
2217
- parent.command("update <idOrKeyOrName>").description("Update a project (lookup by id, key, or name)").option("-n, --name <name>", "Project name").option("-d, --description <description>", "Project description").option("--default", "Set as default project").action(
2221
+ parent.command("update <idOrKeyOrName>").description("Update a project (lookup by id, key, or name)").option("-n, --name <name>", "Project name").option("-d, --description <description>", "Project description").option("--default", "Set as default project").option("--git-remote <url>", "Git remote URL (use --no-git-remote to unlink)").option("--no-git-remote", "Unlink git remote").action(
2218
2222
  (idOrKeyOrName, opts) => {
2219
2223
  const resolved = container.projectService.resolveProject(idOrKeyOrName);
2220
2224
  if (!resolved.ok) {
2221
2225
  handleResult(resolved);
2222
2226
  return;
2223
2227
  }
2224
- const result = container.projectService.updateProject(resolved.value.id, {
2228
+ const updateInput = {
2225
2229
  name: opts.name,
2226
2230
  description: opts.description,
2227
- isDefault: opts.default
2228
- });
2231
+ isDefault: opts.default,
2232
+ ...opts.gitRemote === false ? { gitRemote: null } : typeof opts.gitRemote === "string" ? { gitRemote: opts.gitRemote } : {}
2233
+ };
2234
+ const result = container.projectService.updateProject(resolved.value.id, updateInput);
2229
2235
  handleResult(result);
2230
2236
  }
2231
2237
  );
@@ -2589,12 +2595,145 @@ function buildCLI(container) {
2589
2595
  registerDepGraph(dep, container);
2590
2596
  registerUpgrade(program, container);
2591
2597
  program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
2592
- const { launchTUI } = await import("./tui-WMESKCRD.js");
2598
+ const { launchTUI } = await import("./tui-NKAKDHTY.js");
2593
2599
  await launchTUI(container, opts.project);
2594
2600
  });
2595
2601
  return program;
2596
2602
  }
2597
2603
 
2604
+ // src/telemetry/heartbeat.ts
2605
+ import { readFile, writeFile } from "fs/promises";
2606
+
2607
+ // src/types/install-id.ts
2608
+ import { ulid as ulid2 } from "ulid";
2609
+ var ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
2610
+ var InstallId = class _InstallId {
2611
+ value;
2612
+ constructor(id) {
2613
+ this.value = id;
2614
+ }
2615
+ static generate() {
2616
+ return new _InstallId(ulid2());
2617
+ }
2618
+ static parse(raw) {
2619
+ const trimmed = raw.trim();
2620
+ if (!ULID_REGEX.test(trimmed)) {
2621
+ throw new Error(`Invalid InstallId: expected ULID format, got "${trimmed}"`);
2622
+ }
2623
+ return new _InstallId(trimmed.toUpperCase());
2624
+ }
2625
+ toString() {
2626
+ return this.value;
2627
+ }
2628
+ };
2629
+
2630
+ // src/utils/ci.ts
2631
+ var CI_ENV_VARS = [
2632
+ "CI",
2633
+ "GITHUB_ACTIONS",
2634
+ "JENKINS_URL",
2635
+ "GITLAB_CI",
2636
+ "CIRCLECI",
2637
+ "BUILDKITE",
2638
+ "TF_BUILD",
2639
+ "CODEBUILD_BUILD_ID"
2640
+ ];
2641
+ function isCI(env = process.env) {
2642
+ return CI_ENV_VARS.some((key) => {
2643
+ const val = env[key];
2644
+ return val !== void 0 && val !== "";
2645
+ });
2646
+ }
2647
+
2648
+ // src/telemetry/heartbeat.ts
2649
+ var HEARTBEAT_URL = "https://tayto-telemetry.tomkapa.workers.dev/api/heartbeat";
2650
+ var TIMEOUT_MS = 3e3;
2651
+ function isValidRawState(value) {
2652
+ if (typeof value !== "object" || value === null) return false;
2653
+ const obj = value;
2654
+ return typeof obj["installId"] === "string" && typeof obj["lastPingDate"] === "string";
2655
+ }
2656
+ function todayUTC() {
2657
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2658
+ }
2659
+ async function readState(statePath) {
2660
+ let raw;
2661
+ try {
2662
+ raw = await readFile(statePath, "utf-8");
2663
+ } catch (e) {
2664
+ if (e.code !== "ENOENT") {
2665
+ logger.warn("telemetry: failed to read state", { error: toMessage(e), path: statePath });
2666
+ }
2667
+ return null;
2668
+ }
2669
+ try {
2670
+ const parsed = JSON.parse(raw);
2671
+ if (!isValidRawState(parsed)) return null;
2672
+ return { installId: InstallId.parse(parsed.installId), lastPingDate: parsed.lastPingDate };
2673
+ } catch (e) {
2674
+ logger.warn("telemetry: corrupt state file, will regenerate", {
2675
+ error: toMessage(e),
2676
+ path: statePath
2677
+ });
2678
+ return null;
2679
+ }
2680
+ }
2681
+ async function writeState(statePath, state) {
2682
+ try {
2683
+ await writeFile(statePath, JSON.stringify(state), "utf-8");
2684
+ } catch (e) {
2685
+ logger.warn("telemetry: failed to write state", { error: toMessage(e), path: statePath });
2686
+ }
2687
+ }
2688
+ function maybeSendHeartbeat(deps) {
2689
+ const { statePath, version, fetchImpl = globalThis.fetch, env = process.env } = deps;
2690
+ void (async () => {
2691
+ try {
2692
+ if (env["TASKCLI_TELEMETRY_DISABLED"] === "1") {
2693
+ logger.info("telemetry: disabled via TASKCLI_TELEMETRY_DISABLED");
2694
+ return;
2695
+ }
2696
+ if (isCI(env)) {
2697
+ logger.info("telemetry: skipped in CI environment");
2698
+ return;
2699
+ }
2700
+ const today = todayUTC();
2701
+ const state = await readState(statePath);
2702
+ if (state && state.lastPingDate === today) {
2703
+ return;
2704
+ }
2705
+ const installId = state ? state.installId : InstallId.generate();
2706
+ await writeState(statePath, { installId: installId.toString(), lastPingDate: today });
2707
+ const controller = new AbortController();
2708
+ const timeout = setTimeout(() => {
2709
+ controller.abort();
2710
+ }, TIMEOUT_MS);
2711
+ timeout.unref();
2712
+ const payload = {
2713
+ installId: installId.toString(),
2714
+ version,
2715
+ os: process.platform,
2716
+ nodeVersion: process.version,
2717
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2718
+ };
2719
+ fetchImpl(HEARTBEAT_URL, {
2720
+ method: "POST",
2721
+ headers: { "Content-Type": "application/json" },
2722
+ body: JSON.stringify(payload),
2723
+ signal: controller.signal
2724
+ }).then(() => {
2725
+ clearTimeout(timeout);
2726
+ logger.info("telemetry: heartbeat sent", { installId: installId.toString() });
2727
+ }).catch((e) => {
2728
+ clearTimeout(timeout);
2729
+ logger.info("telemetry: heartbeat failed (non-blocking)", { error: toMessage(e) });
2730
+ });
2731
+ } catch (e) {
2732
+ logger.warn("telemetry: unexpected error in maybeSendHeartbeat", { error: toMessage(e) });
2733
+ }
2734
+ })();
2735
+ }
2736
+
2598
2737
  // src/index.ts
2599
2738
  async function checkForUpdateQuietly(container, currentVersion) {
2600
2739
  let timerId;
@@ -2621,9 +2760,16 @@ async function main() {
2621
2760
  const config = loadConfig();
2622
2761
  logger.init(config.logDir);
2623
2762
  initTelemetry(config);
2763
+ maybeSendHeartbeat({ statePath: config.telemetryStatePath, version: APP_VERSION });
2624
2764
  const db = createDatabase(config.dbPath);
2625
2765
  runMigrations(db);
2626
- const container = createContainer(db, config.dbPath, void 0, config.updateCachePath);
2766
+ const container = createContainer(
2767
+ db,
2768
+ config.dbPath,
2769
+ void 0,
2770
+ config.updateCachePath,
2771
+ config.dismissedGitRemotesPath
2772
+ );
2627
2773
  const args = process.argv.slice(2);
2628
2774
  const isUpgradeCommand = args[0] === "upgrade";
2629
2775
  let updateCheck = null;
@@ -2631,7 +2777,7 @@ async function main() {
2631
2777
  updateCheck = await checkForUpdateQuietly(container, APP_VERSION);
2632
2778
  }
2633
2779
  if (args.length === 0) {
2634
- const { launchTUI } = await import("./tui-WMESKCRD.js");
2780
+ const { launchTUI } = await import("./tui-NKAKDHTY.js");
2635
2781
  await launchTUI(
2636
2782
  container,
2637
2783
  void 0,