@tomkapa/tayto 0.5.4 → 0.7.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.
@@ -154,6 +154,38 @@ function err(error) {
154
154
  return { ok: false, error };
155
155
  }
156
156
 
157
+ // src/types/git-remote.ts
158
+ var GitRemote = class _GitRemote {
159
+ value;
160
+ constructor(normalized) {
161
+ this.value = normalized;
162
+ }
163
+ static parse(raw) {
164
+ return new _GitRemote(_GitRemote.normalize(raw));
165
+ }
166
+ static normalize(url) {
167
+ const trimmed = url.trim();
168
+ const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
169
+ if (sshMatch) {
170
+ const [, host, path] = sshMatch;
171
+ return `${host}/${path}`.toLowerCase();
172
+ }
173
+ try {
174
+ const parsed = new URL(trimmed);
175
+ const path = parsed.pathname.replace(/\.git$/, "").replace(/^\//, "");
176
+ return `${parsed.host}/${path}`.toLowerCase();
177
+ } catch {
178
+ return trimmed.toLowerCase();
179
+ }
180
+ }
181
+ equals(other) {
182
+ return this.value === other.value;
183
+ }
184
+ toString() {
185
+ return this.value;
186
+ }
187
+ };
188
+
157
189
  // src/utils/git.ts
158
190
  function detectGitRemote(cwd) {
159
191
  try {
@@ -166,12 +198,13 @@ function detectGitRemote(cwd) {
166
198
  logger.info("detectGitRemote: no git remote found (non-zero exit or error)");
167
199
  return ok(null);
168
200
  }
169
- const remote = result.stdout.trim();
170
- if (!remote) {
201
+ const raw = result.stdout.trim();
202
+ if (!raw) {
171
203
  logger.info("detectGitRemote: empty stdout from git remote get-url");
172
204
  return ok(null);
173
205
  }
174
- logger.info(`detectGitRemote: found remote=${remote}`);
206
+ const remote = GitRemote.parse(raw);
207
+ logger.info(`detectGitRemote: found remote=${remote.value}`);
175
208
  return ok(remote);
176
209
  } catch (e) {
177
210
  logger.info(`detectGitRemote: exception during git detection: ${String(e)}`);
@@ -183,6 +216,7 @@ export {
183
216
  logger,
184
217
  ok,
185
218
  err,
219
+ GitRemote,
186
220
  TaskStatus,
187
221
  TaskType,
188
222
  TaskLevel,
@@ -196,4 +230,4 @@ export {
196
230
  isTerminalStatus,
197
231
  detectGitRemote
198
232
  };
199
- //# sourceMappingURL=chunk-74Q55TOV.js.map
233
+ //# sourceMappingURL=chunk-5V4TBQ5S.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logging/logger.ts","../src/types/enums.ts","../src/utils/git.ts","../src/types/common.ts","../src/types/git-remote.ts"],"sourcesContent":["import { appendFileSync, readdirSync, unlinkSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { trace, type Span, SpanStatusCode } from '@opentelemetry/api';\n\nconst tracer = trace.getTracer('task');\nconst LOG_RETENTION_DAYS = 7;\n\nexport interface LogAttributes {\n [key: string]: string | number | boolean;\n}\n\ntype LogLevel = 'INFO' | 'WARN' | 'ERROR';\n\nfunction formatTimestamp(): string {\n return new Date().toISOString();\n}\n\nfunction formatAttrs(attrs?: LogAttributes): string {\n if (!attrs || Object.keys(attrs).length === 0) return '';\n return ' ' + JSON.stringify(attrs);\n}\n\nclass Logger {\n private logFilePath: string | null = null;\n\n init(logDir: string): void {\n const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD\n this.logFilePath = join(logDir, `task-${date}.log`);\n this.pruneOldLogs(logDir);\n }\n\n info(message: string, attrs?: LogAttributes): void {\n this.write('INFO', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(message, attrs);\n }\n }\n\n warn(message: string, attrs?: LogAttributes): void {\n this.write('WARN', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`WARN: ${message}`, attrs);\n }\n }\n\n error(message: string, error?: unknown, attrs?: LogAttributes): void {\n const errorDetail = error instanceof Error ? ` | ${error.stack ?? error.message}` : '';\n this.write('ERROR', `${message}${errorDetail}`, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`ERROR: ${message}`, attrs);\n if (error instanceof Error) {\n span.recordException(error);\n }\n span.setStatus({ code: SpanStatusCode.ERROR, message });\n }\n }\n\n startSpan<T>(name: string, fn: (span: Span) => T): T {\n return tracer.startActiveSpan(name, (span) => {\n try {\n const result = fn(span);\n span.end();\n return result;\n } catch (e) {\n if (e instanceof Error) {\n span.recordException(e);\n }\n span.setStatus({ code: SpanStatusCode.ERROR });\n span.end();\n throw e;\n }\n });\n }\n\n private write(level: LogLevel, message: string, attrs?: LogAttributes): void {\n if (!this.logFilePath) return;\n const line = `${formatTimestamp()} [${level}] ${message}${formatAttrs(attrs)}\\n`;\n try {\n appendFileSync(this.logFilePath, line);\n } catch {\n // Swallowing here is intentional: logging must never crash the app.\n // If the log file is unwritable, the OTel span still captures the event.\n }\n }\n\n private pruneOldLogs(logDir: string): void {\n try {\n const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n const files = readdirSync(logDir).filter((f) => f.startsWith('task-') && f.endsWith('.log'));\n for (const file of files) {\n const dateStr = file.slice('task-'.length, -'.log'.length);\n const fileDate = new Date(dateStr).getTime();\n if (!isNaN(fileDate) && fileDate < cutoff) {\n unlinkSync(join(logDir, file));\n }\n }\n } catch {\n // Best-effort cleanup — don't crash if pruning fails\n }\n }\n}\n\nexport const logger = new Logger();\n","export const TaskStatus = {\n Backlog: 'backlog',\n Todo: 'todo',\n InProgress: 'in-progress',\n Review: 'review',\n Done: 'done',\n Cancelled: 'cancelled',\n} as const;\nexport type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];\n\nexport const TaskType = {\n Epic: 'epic',\n Story: 'story',\n TechDebt: 'tech-debt',\n Bug: 'bug',\n} as const;\nexport type TaskType = (typeof TaskType)[keyof typeof TaskType];\n\n/**\n * Task level derived from type.\n * Level 1: epics (grouping/planning layer)\n * Level 2: stories, tech-debt, bugs (execution layer)\n */\nexport const TaskLevel = {\n Epic: 1,\n Work: 2,\n} as const;\nexport type TaskLevel = (typeof TaskLevel)[keyof typeof TaskLevel];\n\nconst TYPE_TO_LEVEL: Record<string, TaskLevel> = {\n [TaskType.Epic]: TaskLevel.Epic,\n [TaskType.Story]: TaskLevel.Work,\n [TaskType.TechDebt]: TaskLevel.Work,\n [TaskType.Bug]: TaskLevel.Work,\n};\n\nexport function getTaskLevel(type: string): TaskLevel {\n return TYPE_TO_LEVEL[type] ?? TaskLevel.Work;\n}\n\n/** Types that belong to the work (level 2) execution layer. */\nexport const WORK_TYPES: ReadonlySet<string> = new Set([\n TaskType.Story,\n TaskType.TechDebt,\n TaskType.Bug,\n]);\n\n/** Types stored in the database. */\nexport const DependencyType = {\n Blocks: 'blocks',\n RelatesTo: 'relates-to',\n Duplicates: 'duplicates',\n} as const;\nexport type DependencyType = (typeof DependencyType)[keyof typeof DependencyType];\n\n/**\n * UI-level dependency types — includes BlockedBy which is a reverse-Blocks\n * relationship resolved before persisting to the database.\n */\nexport const UIDependencyType = {\n ...DependencyType,\n BlockedBy: 'blocked-by',\n} as const;\nexport type UIDependencyType = (typeof UIDependencyType)[keyof typeof UIDependencyType];\n\n/** Gap between consecutive rank values, used for insertion between neighbors. */\nexport const RANK_GAP = 1000.0;\n\n/**\n * Collapse-safe midpoint between two rank values. Returns `null` when\n * IEEE 754 double precision cannot represent a strictly-between value —\n * callers use this as a signal to rebalance the rank grid and retry.\n * Returning a number unconditionally would silently collide with an\n * endpoint and corrupt the task ordering.\n */\nexport function midpoint(a: number, b: number): number | null {\n const m = (a + b) / 2;\n return m > a && m < b ? m : null;\n}\n\n/** Statuses that represent terminal/completed task states. */\nexport const TERMINAL_STATUSES: ReadonlySet<string> = new Set([\n TaskStatus.Done,\n TaskStatus.Cancelled,\n]);\n\nexport function isTerminalStatus(status: string): boolean {\n return TERMINAL_STATUSES.has(status);\n}\n","import { spawnSync } from 'node:child_process';\nimport type { Result } from '../types/common.js';\nimport { ok } from '../types/common.js';\nimport { GitRemote } from '../types/git-remote.js';\nimport { logger } from '../logging/logger.js';\n\n/**\n * Detect the git origin remote URL from the given directory.\n * Returns ok(GitRemote) if found, ok(null) if no git repo or no origin remote.\n * Never returns err() — all git failures are silent fallbacks.\n */\nexport function detectGitRemote(cwd?: string): Result<GitRemote | null> {\n try {\n const result = spawnSync('git', ['remote', 'get-url', 'origin'], {\n cwd: cwd ?? process.cwd(),\n encoding: 'utf-8',\n timeout: 5000,\n });\n\n if (result.status !== 0 || result.error) {\n logger.info('detectGitRemote: no git remote found (non-zero exit or error)');\n return ok(null);\n }\n\n const raw = result.stdout.trim();\n if (!raw) {\n logger.info('detectGitRemote: empty stdout from git remote get-url');\n return ok(null);\n }\n\n const remote = GitRemote.parse(raw);\n logger.info(`detectGitRemote: found remote=${remote.value}`);\n return ok(remote);\n } catch (e: unknown) {\n logger.info(`detectGitRemote: exception during git detection: ${String(e)}`);\n return ok(null);\n }\n}\n","import type { AppError } from '../errors/app-error.js';\n\nexport type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };\n\nexport function ok<T>(value: T): Result<T, never> {\n return { ok: true, value };\n}\n\nexport function err<E>(error: E): Result<never, E> {\n return { ok: false, error };\n}\n\nexport interface CLIOutput<T> {\n ok: boolean;\n data?: T;\n error?: { code: string; message: string };\n}\n","/**\n * Value object representing a normalized git remote URL.\n * Accepts any common format (SSH or HTTPS, with or without .git suffix)\n * and stores the canonical `host/owner/repo` form.\n */\nexport class GitRemote {\n readonly value: string;\n\n private constructor(normalized: string) {\n this.value = normalized;\n }\n\n static parse(raw: string): GitRemote {\n return new GitRemote(GitRemote.normalize(raw));\n }\n\n private static normalize(url: string): string {\n const trimmed = url.trim();\n\n // SSH format: git@github.com:owner/repo.git or git@github.com:owner/repo\n const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\\.git)?$/);\n if (sshMatch) {\n const [, host, path] = sshMatch;\n return `${host}/${path}`.toLowerCase();\n }\n\n // HTTPS/HTTP format: https://github.com/owner/repo.git or without .git\n try {\n const parsed = new URL(trimmed);\n const path = parsed.pathname.replace(/\\.git$/, '').replace(/^\\//, '');\n return `${parsed.host}/${path}`.toLowerCase();\n } catch {\n return trimmed.toLowerCase();\n }\n }\n\n equals(other: GitRemote): boolean {\n return this.value === other.value;\n }\n\n toString(): string {\n return this.value;\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  DependencyType,
4
+ GitRemote,
4
5
  RANK_GAP,
5
6
  TERMINAL_STATUSES,
6
7
  TaskLevel,
@@ -15,7 +16,7 @@ import {
15
16
  logger,
16
17
  midpoint,
17
18
  ok
18
- } from "./chunk-74Q55TOV.js";
19
+ } from "./chunk-5V4TBQ5S.js";
19
20
 
20
21
  // src/config/index.ts
21
22
  import { mkdirSync } from "fs";
@@ -33,7 +34,10 @@ function loadConfig() {
33
34
  dbPath: process.env["TASK_DB_PATH"] ?? join(dataDir, "data.db"),
34
35
  logDir,
35
36
  logLevel: process.env["TASK_LOG_LEVEL"] ?? "info",
36
- otelEndpoint: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
37
+ otelEndpoint: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
38
+ updateCachePath: join(dataDir, "update-check.json"),
39
+ dismissedGitRemotesPath: join(dataDir, "dismissed-git-remotes.json"),
40
+ noUpdateCheck: process.env["TAYTO_NO_UPDATE_CHECK"] === "1"
37
41
  };
38
42
  }
39
43
 
@@ -109,6 +113,10 @@ async function shutdownTelemetry() {
109
113
  }
110
114
  }
111
115
 
116
+ // src/cli/container.ts
117
+ import { join as join3 } from "path";
118
+ import { tmpdir } from "os";
119
+
112
120
  // src/errors/app-error.ts
113
121
  var AppError = class extends Error {
114
122
  constructor(code, message, cause) {
@@ -120,6 +128,9 @@ var AppError = class extends Error {
120
128
  code;
121
129
  cause;
122
130
  };
131
+ function toMessage(e) {
132
+ return e instanceof Error ? e.message : String(e);
133
+ }
123
134
 
124
135
  // src/repository/project.repository.ts
125
136
  import { ulid } from "ulid";
@@ -151,7 +162,7 @@ function rowToProject(row) {
151
162
  name: row.name,
152
163
  description: row.description,
153
164
  isDefault: row.is_default === 1,
154
- gitRemote: row.git_remote,
165
+ gitRemote: row.git_remote ? GitRemote.parse(row.git_remote) : null,
155
166
  createdAt: row.created_at,
156
167
  updatedAt: row.updated_at
157
168
  };
@@ -178,7 +189,7 @@ var SqliteProjectRepository = class {
178
189
  input.name,
179
190
  input.description ?? "",
180
191
  input.isDefault ? 1 : 0,
181
- input.gitRemote ?? null,
192
+ input.gitRemote?.value ?? null,
182
193
  now,
183
194
  now
184
195
  );
@@ -193,7 +204,7 @@ var SqliteProjectRepository = class {
193
204
  return err(
194
205
  new AppError(
195
206
  "DUPLICATE",
196
- `Git remote already linked to another project: ${input.gitRemote}`,
207
+ `Git remote already linked to another project: ${input.gitRemote?.value}`,
197
208
  e
198
209
  )
199
210
  );
@@ -230,7 +241,7 @@ var SqliteProjectRepository = class {
230
241
  }
231
242
  findByGitRemote(remote) {
232
243
  try {
233
- const row = this.db.prepare(`SELECT * FROM projects WHERE git_remote = ? AND ${NOT_DELETED}`).get(remote);
244
+ const row = this.db.prepare(`SELECT * FROM projects WHERE git_remote = ? AND ${NOT_DELETED}`).get(remote.value);
234
245
  return ok(row ? rowToProject(row) : null);
235
246
  } catch (e) {
236
247
  return err(new AppError("DB_ERROR", "Failed to find project by git remote", e));
@@ -271,7 +282,7 @@ var SqliteProjectRepository = class {
271
282
  input.name ?? existing.name,
272
283
  input.description ?? existing.description,
273
284
  input.isDefault !== void 0 ? input.isDefault ? 1 : 0 : existing.is_default,
274
- input.gitRemote !== void 0 ? input.gitRemote : existing.git_remote,
285
+ input.gitRemote !== void 0 ? input.gitRemote?.value ?? null : existing.git_remote,
275
286
  now,
276
287
  id
277
288
  );
@@ -958,18 +969,19 @@ var SqliteDependencyRepository = class {
958
969
 
959
970
  // src/types/project.ts
960
971
  import { z } from "zod/v4";
972
+ var gitRemoteField = z.string().min(1, "Git remote URL must not be empty").transform((v) => GitRemote.parse(v)).nullable().optional();
961
973
  var CreateProjectSchema = z.object({
962
974
  name: z.string().min(1, "Project name is required").max(255),
963
975
  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(),
964
976
  description: z.string().max(5e3).optional(),
965
977
  isDefault: z.boolean().optional(),
966
- gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
978
+ gitRemote: gitRemoteField
967
979
  });
968
980
  var UpdateProjectSchema = z.object({
969
981
  name: z.string().min(1).max(255).optional(),
970
982
  description: z.string().max(5e3).optional(),
971
983
  isDefault: z.boolean().optional(),
972
- gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
984
+ gitRemote: gitRemoteField
973
985
  });
974
986
 
975
987
  // src/service/project.service.ts
@@ -1074,8 +1086,10 @@ var ProjectServiceImpl = class {
1074
1086
  return logger.startSpan("ProjectService.linkGitRemote", () => {
1075
1087
  const resolved = this.resolveProject(idOrName);
1076
1088
  if (!resolved.ok) return resolved;
1077
- let url = remote;
1078
- if (!url) {
1089
+ let gitRemote;
1090
+ if (remote) {
1091
+ gitRemote = GitRemote.parse(remote);
1092
+ } else {
1079
1093
  const detected = this.detectRemote();
1080
1094
  if (!detected.ok) return detected;
1081
1095
  if (!detected.value) {
@@ -1086,9 +1100,9 @@ var ProjectServiceImpl = class {
1086
1100
  )
1087
1101
  );
1088
1102
  }
1089
- url = detected.value;
1103
+ gitRemote = detected.value;
1090
1104
  }
1091
- return this.repo.update(resolved.value.id, { gitRemote: url });
1105
+ return this.repo.update(resolved.value.id, { gitRemote });
1092
1106
  });
1093
1107
  }
1094
1108
  unlinkGitRemote(idOrName) {
@@ -1997,8 +2011,141 @@ var PortabilityServiceImpl = class {
1997
2011
  }
1998
2012
  };
1999
2013
 
2014
+ // src/service/update.service.ts
2015
+ import { readFileSync as readFileSync2, writeFileSync } from "fs";
2016
+ import { execFileSync } from "child_process";
2017
+ import { trace } from "@opentelemetry/api";
2018
+ var NPM_REGISTRY_URL = "https://registry.npmjs.org/@tomkapa/tayto/latest";
2019
+ var PACKAGE_NAME = "@tomkapa/tayto";
2020
+ var CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
2021
+ function isValidCache(value) {
2022
+ if (typeof value !== "object" || value === null) return false;
2023
+ const obj = value;
2024
+ return typeof obj["checkedAt"] === "number" && typeof obj["latestVersion"] === "string";
2025
+ }
2026
+ function isNewerVersion(a, b) {
2027
+ const parse = (v) => {
2028
+ const parts = v.replace(/^v/, "").split(".").map(Number);
2029
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
2030
+ };
2031
+ const [aMaj, aMin, aPatch] = parse(a);
2032
+ const [bMaj, bMin, bPatch] = parse(b);
2033
+ if (aMaj !== bMaj) return aMaj > bMaj;
2034
+ if (aMin !== bMin) return aMin > bMin;
2035
+ return aPatch > bPatch;
2036
+ }
2037
+ function defaultExec(cmd, args) {
2038
+ execFileSync(cmd, args, { stdio: "inherit", timeout: 6e4 });
2039
+ }
2040
+ var tracer = trace.getTracer("task");
2041
+ var UpdateServiceImpl = class {
2042
+ constructor(cachePath, fetchImpl = globalThis.fetch, execImpl = defaultExec) {
2043
+ this.cachePath = cachePath;
2044
+ this.fetchImpl = fetchImpl;
2045
+ this.execImpl = execImpl;
2046
+ }
2047
+ cachePath;
2048
+ fetchImpl;
2049
+ execImpl;
2050
+ async checkForUpdate(currentVersion) {
2051
+ return tracer.startActiveSpan("UpdateService.checkForUpdate", async (span) => {
2052
+ try {
2053
+ span.setAttribute("update.current_version", currentVersion);
2054
+ const cached = this.readCache();
2055
+ if (cached && Date.now() - cached.checkedAt < CHECK_TTL_MS) {
2056
+ span.setAttribute("update.cache_hit", true);
2057
+ const updateAvailable2 = isNewerVersion(cached.latestVersion, currentVersion);
2058
+ span.setAttribute("update.latest_version", cached.latestVersion);
2059
+ span.setAttribute("update.available", updateAvailable2);
2060
+ return ok({ currentVersion, latestVersion: cached.latestVersion, updateAvailable: updateAvailable2 });
2061
+ }
2062
+ span.setAttribute("update.cache_hit", false);
2063
+ let latestVersion;
2064
+ const controller = new AbortController();
2065
+ const timeout = setTimeout(() => {
2066
+ controller.abort();
2067
+ }, 5e3);
2068
+ try {
2069
+ const response = await this.fetchImpl(NPM_REGISTRY_URL, { signal: controller.signal });
2070
+ if (!response.ok) {
2071
+ logger.warn("Update check: registry returned non-OK status", {
2072
+ status: response.status
2073
+ });
2074
+ return err(
2075
+ new AppError(
2076
+ "UPGRADE_CHECK",
2077
+ `npm registry returned status ${String(response.status)}`
2078
+ )
2079
+ );
2080
+ }
2081
+ const body = await response.json();
2082
+ if (typeof body["version"] !== "string") {
2083
+ return err(
2084
+ new AppError("UPGRADE_CHECK", "Unexpected response format from npm registry")
2085
+ );
2086
+ }
2087
+ latestVersion = body["version"];
2088
+ } catch (e) {
2089
+ logger.warn("Update check: fetch failed", { error: toMessage(e) });
2090
+ return err(
2091
+ new AppError("UPGRADE_CHECK", `Failed to check for updates: ${toMessage(e)}`, e)
2092
+ );
2093
+ } finally {
2094
+ clearTimeout(timeout);
2095
+ }
2096
+ this.writeCache({ checkedAt: Date.now(), latestVersion });
2097
+ const updateAvailable = isNewerVersion(latestVersion, currentVersion);
2098
+ span.setAttribute("update.latest_version", latestVersion);
2099
+ span.setAttribute("update.available", updateAvailable);
2100
+ return ok({ currentVersion, latestVersion, updateAvailable });
2101
+ } finally {
2102
+ span.end();
2103
+ }
2104
+ });
2105
+ }
2106
+ performUpgrade(currentVersion) {
2107
+ return logger.startSpan("UpdateService.performUpgrade", (span) => {
2108
+ span.setAttribute("update.previous_version", currentVersion);
2109
+ try {
2110
+ this.execImpl("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
2111
+ } catch (e) {
2112
+ logger.error("Upgrade failed", e, { command: `npm install -g ${PACKAGE_NAME}@latest` });
2113
+ span.setAttribute("update.success", false);
2114
+ return err(new AppError("UPGRADE_CHECK", `Upgrade failed: ${toMessage(e)}`, e));
2115
+ }
2116
+ const cached = this.readCache();
2117
+ const installedVersion = cached?.latestVersion ?? "unknown";
2118
+ span.setAttribute("update.success", true);
2119
+ span.setAttribute("update.installed_version", installedVersion);
2120
+ this.writeCache({ checkedAt: 0, latestVersion: installedVersion });
2121
+ return ok({ installedVersion });
2122
+ });
2123
+ }
2124
+ readCache() {
2125
+ try {
2126
+ const raw = readFileSync2(this.cachePath, "utf-8");
2127
+ const parsed = JSON.parse(raw);
2128
+ return isValidCache(parsed) ? parsed : null;
2129
+ } catch (e) {
2130
+ logger.info("Update cache not available", {
2131
+ error: toMessage(e)
2132
+ });
2133
+ return null;
2134
+ }
2135
+ }
2136
+ writeCache(entry) {
2137
+ try {
2138
+ writeFileSync(this.cachePath, JSON.stringify(entry), "utf-8");
2139
+ } catch (e) {
2140
+ logger.warn("Failed to write update cache", {
2141
+ error: toMessage(e)
2142
+ });
2143
+ }
2144
+ }
2145
+ };
2146
+
2000
2147
  // src/cli/container.ts
2001
- function createContainer(db, dbPath, detectGitRemote2) {
2148
+ function createContainer(db, dbPath, detectGitRemote2, updateCachePath, dismissedGitRemotesPath) {
2002
2149
  const projectRepo = new SqliteProjectRepository(db);
2003
2150
  const taskRepo = new SqliteTaskRepository(db);
2004
2151
  const depRepo = new SqliteDependencyRepository(db);
@@ -2006,12 +2153,26 @@ function createContainer(db, dbPath, detectGitRemote2) {
2006
2153
  const dependencyService = new DependencyServiceImpl(depRepo, taskRepo);
2007
2154
  const taskService = new TaskServiceImpl(taskRepo, projectService, () => dependencyService);
2008
2155
  const portabilityService = new PortabilityServiceImpl(taskService, dependencyService);
2009
- return { dbPath, projectService, taskService, dependencyService, portabilityService };
2156
+ const updateService = new UpdateServiceImpl(
2157
+ updateCachePath ?? join3(tmpdir(), "tayto-update-check.json")
2158
+ );
2159
+ return {
2160
+ dbPath,
2161
+ dismissedGitRemotesPath: dismissedGitRemotesPath ?? join3(tmpdir(), "tayto-dismissed-git-remotes.json"),
2162
+ projectService,
2163
+ taskService,
2164
+ dependencyService,
2165
+ portabilityService,
2166
+ updateService
2167
+ };
2010
2168
  }
2011
2169
 
2012
2170
  // src/cli/index.ts
2013
2171
  import { Command } from "commander";
2014
2172
 
2173
+ // src/version.ts
2174
+ var APP_VERSION = true ? "0.7.0" : "0.0.0-dev";
2175
+
2015
2176
  // src/cli/output.ts
2016
2177
  function printSuccess(data) {
2017
2178
  process.stdout.write(JSON.stringify({ ok: true, data }, null, 2) + "\n");
@@ -2059,18 +2220,20 @@ function registerProjectList(parent, container) {
2059
2220
 
2060
2221
  // src/cli/commands/project/update.ts
2061
2222
  function registerProjectUpdate(parent, container) {
2062
- 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(
2223
+ 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(
2063
2224
  (idOrKeyOrName, opts) => {
2064
2225
  const resolved = container.projectService.resolveProject(idOrKeyOrName);
2065
2226
  if (!resolved.ok) {
2066
2227
  handleResult(resolved);
2067
2228
  return;
2068
2229
  }
2069
- const result = container.projectService.updateProject(resolved.value.id, {
2230
+ const updateInput = {
2070
2231
  name: opts.name,
2071
2232
  description: opts.description,
2072
- isDefault: opts.default
2073
- });
2233
+ isDefault: opts.default,
2234
+ ...opts.gitRemote === false ? { gitRemote: null } : typeof opts.gitRemote === "string" ? { gitRemote: opts.gitRemote } : {}
2235
+ };
2236
+ const result = container.projectService.updateProject(resolved.value.id, updateInput);
2074
2237
  handleResult(result);
2075
2238
  }
2076
2239
  );
@@ -2204,12 +2367,12 @@ function registerTaskDelete(parent, container) {
2204
2367
  }
2205
2368
 
2206
2369
  // src/cli/commands/task/breakdown.ts
2207
- import { readFileSync as readFileSync2 } from "fs";
2370
+ import { readFileSync as readFileSync3 } from "fs";
2208
2371
  function registerTaskBreakdown(parent, container) {
2209
2372
  parent.command("breakdown <parentId>").description("Create subtasks from a JSON file").requiredOption("-f, --file <path>", "JSON file with array of subtask definitions").action((parentId, opts) => {
2210
2373
  let content;
2211
2374
  try {
2212
- content = readFileSync2(opts.file, "utf-8");
2375
+ content = readFileSync3(opts.file, "utf-8");
2213
2376
  } catch (e) {
2214
2377
  return printError(
2215
2378
  new AppError("VALIDATION", `Failed to read subtasks file: ${opts.file}`, e)
@@ -2263,7 +2426,7 @@ function registerTaskSearch(parent, container) {
2263
2426
  }
2264
2427
 
2265
2428
  // src/cli/commands/task/export.ts
2266
- import { writeFileSync } from "fs";
2429
+ import { writeFileSync as writeFileSync2 } from "fs";
2267
2430
  function registerTaskExport(parent, container) {
2268
2431
  parent.command("export").description("Export tasks to JSON file").option("-p, --project <project>", "Project id or name").option("-o, --output <file>", "Output file path (defaults to stdout)").action((opts) => {
2269
2432
  const projectResult = withProject(container, opts.project);
@@ -2274,7 +2437,7 @@ function registerTaskExport(parent, container) {
2274
2437
  }
2275
2438
  if (opts.output) {
2276
2439
  try {
2277
- writeFileSync(opts.output, JSON.stringify(result.value, null, 2) + "\n", "utf-8");
2440
+ writeFileSync2(opts.output, JSON.stringify(result.value, null, 2) + "\n", "utf-8");
2278
2441
  } catch (e) {
2279
2442
  return printError(new AppError("UNKNOWN", `Failed to write file: ${opts.output}`, e));
2280
2443
  }
@@ -2290,7 +2453,7 @@ function registerTaskExport(parent, container) {
2290
2453
  }
2291
2454
 
2292
2455
  // src/cli/commands/task/import.ts
2293
- import { readFileSync as readFileSync3 } from "fs";
2456
+ import { readFileSync as readFileSync4 } from "fs";
2294
2457
  function registerTaskImport(parent, container) {
2295
2458
  parent.command("import").description("Import tasks from JSON file").requiredOption("-f, --file <file>", "Input JSON file path").option("-p, --project <project>", "Target project id or name").option(
2296
2459
  "--map <mapping>",
@@ -2298,7 +2461,7 @@ function registerTaskImport(parent, container) {
2298
2461
  ).action((opts) => {
2299
2462
  let fileData;
2300
2463
  try {
2301
- const raw = readFileSync3(opts.file, "utf-8");
2464
+ const raw = readFileSync4(opts.file, "utf-8");
2302
2465
  fileData = JSON.parse(raw);
2303
2466
  } catch (e) {
2304
2467
  return printError(
@@ -2372,10 +2535,42 @@ function registerDepGraph(parent, container) {
2372
2535
  });
2373
2536
  }
2374
2537
 
2538
+ // src/cli/commands/upgrade.ts
2539
+ function registerUpgrade(program, container) {
2540
+ program.command("upgrade").description("Check for updates and upgrade to the latest version").action(async () => {
2541
+ const checkResult = await container.updateService.checkForUpdate(APP_VERSION);
2542
+ if (!checkResult.ok) {
2543
+ printError(checkResult.error);
2544
+ return;
2545
+ }
2546
+ if (!checkResult.value.updateAvailable) {
2547
+ printSuccess({
2548
+ message: "Already up to date",
2549
+ currentVersion: checkResult.value.currentVersion
2550
+ });
2551
+ return;
2552
+ }
2553
+ process.stderr.write(
2554
+ `Upgrading from ${checkResult.value.currentVersion} to ${checkResult.value.latestVersion}...
2555
+ `
2556
+ );
2557
+ const upgradeResult = container.updateService.performUpgrade(APP_VERSION);
2558
+ if (!upgradeResult.ok) {
2559
+ printError(upgradeResult.error);
2560
+ return;
2561
+ }
2562
+ printSuccess({
2563
+ message: "Upgrade complete",
2564
+ previousVersion: checkResult.value.currentVersion,
2565
+ installedVersion: upgradeResult.value.installedVersion
2566
+ });
2567
+ });
2568
+ }
2569
+
2375
2570
  // src/cli/index.ts
2376
2571
  function buildCLI(container) {
2377
2572
  const program = new Command();
2378
- program.name("tayto").description("CLI task management for solo devs and AI agents").version("0.1.0");
2573
+ program.name("tayto").description("CLI task management for solo devs and AI agents").version(APP_VERSION);
2379
2574
  const project = program.command("project").description("Manage projects");
2380
2575
  registerProjectCreate(project, container);
2381
2576
  registerProjectList(project, container);
@@ -2400,26 +2595,63 @@ function buildCLI(container) {
2400
2595
  registerDepRemove(dep, container);
2401
2596
  registerDepList(dep, container);
2402
2597
  registerDepGraph(dep, container);
2598
+ registerUpgrade(program, container);
2403
2599
  program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
2404
- const { launchTUI } = await import("./tui-24ZW56Q6.js");
2600
+ const { launchTUI } = await import("./tui-NCL4RFFD.js");
2405
2601
  await launchTUI(container, opts.project);
2406
2602
  });
2407
2603
  return program;
2408
2604
  }
2409
2605
 
2410
2606
  // src/index.ts
2607
+ async function checkForUpdateQuietly(container, currentVersion) {
2608
+ let timerId;
2609
+ try {
2610
+ const result = await Promise.race([
2611
+ container.updateService.checkForUpdate(currentVersion),
2612
+ new Promise((resolve) => {
2613
+ timerId = setTimeout(() => {
2614
+ resolve(null);
2615
+ }, 2e3);
2616
+ })
2617
+ ]);
2618
+ clearTimeout(timerId);
2619
+ if (result !== null && result.ok) {
2620
+ return result.value;
2621
+ }
2622
+ return null;
2623
+ } catch {
2624
+ clearTimeout(timerId);
2625
+ return null;
2626
+ }
2627
+ }
2411
2628
  async function main() {
2412
2629
  const config = loadConfig();
2413
2630
  logger.init(config.logDir);
2414
2631
  initTelemetry(config);
2415
2632
  const db = createDatabase(config.dbPath);
2416
2633
  runMigrations(db);
2417
- const container = createContainer(db, config.dbPath);
2634
+ const container = createContainer(db, config.dbPath, void 0, config.updateCachePath, config.dismissedGitRemotesPath);
2418
2635
  const args = process.argv.slice(2);
2636
+ const isUpgradeCommand = args[0] === "upgrade";
2637
+ let updateCheck = null;
2638
+ if (!config.noUpdateCheck && !isUpgradeCommand) {
2639
+ updateCheck = await checkForUpdateQuietly(container, APP_VERSION);
2640
+ }
2419
2641
  if (args.length === 0) {
2420
- const { launchTUI } = await import("./tui-24ZW56Q6.js");
2421
- await launchTUI(container);
2642
+ const { launchTUI } = await import("./tui-NCL4RFFD.js");
2643
+ await launchTUI(
2644
+ container,
2645
+ void 0,
2646
+ updateCheck?.updateAvailable ? updateCheck.latestVersion : void 0
2647
+ );
2422
2648
  } else {
2649
+ if (updateCheck?.updateAvailable) {
2650
+ process.stderr.write(
2651
+ `\x1B[33m[tayto]\x1B[0m Update available: ${updateCheck.currentVersion} \u2192 ${updateCheck.latestVersion}. Run: tayto upgrade
2652
+ `
2653
+ );
2654
+ }
2423
2655
  const program = buildCLI(container);
2424
2656
  await program.parseAsync(process.argv);
2425
2657
  }