From 319857b0b405916c80029de7d84226d36478dd0d Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Thu, 23 Apr 2026 15:58:19 +0300
Subject: [PATCH 01/55] update node requirement
---
CONTRIBUTING.md | 2 +-
README.md | 2 +-
cli/services/runtime/node-version-guard.test.ts | 11 ++++++-----
cli/services/runtime/node-version-guard.ts | 2 +-
docs/aso-runtime-flows.md | 2 +-
package-lock.json | 2 +-
package.json | 2 +-
7 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a9e74fb..fe2deaf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,7 +9,7 @@ npm run build
```
Runtime requirement:
-- Runtime: Node.js `>=20.18.1`
+- Runtime: Node.js `>=18.14.1`
- Local development/build: Node.js `>=20.19.0` (enforced by script checks)
## Development workflow
diff --git a/README.md b/README.md
index 2aca51f..e9c5e00 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ The dashboard keywords shown above were discovered and added automatically by an
npm install -g aso-cli
```
-Note: requires Node.js `>=20.18.1`.
+Note: requires Node.js `>=18.14.1`.
## Apple Search Ads Setup
diff --git a/cli/services/runtime/node-version-guard.test.ts b/cli/services/runtime/node-version-guard.test.ts
index bcc6027..4f7244f 100644
--- a/cli/services/runtime/node-version-guard.test.ts
+++ b/cli/services/runtime/node-version-guard.test.ts
@@ -2,17 +2,18 @@ import { assertSupportedNodeVersion } from "./node-version-guard";
describe("assertSupportedNodeVersion", () => {
it("does not throw for supported versions", () => {
- expect(() => assertSupportedNodeVersion("v20.18.1")).not.toThrow();
+ expect(() => assertSupportedNodeVersion("v18.14.1")).not.toThrow();
+ expect(() => assertSupportedNodeVersion("v18.17.0")).not.toThrow();
expect(() => assertSupportedNodeVersion("v20.19.5")).not.toThrow();
expect(() => assertSupportedNodeVersion("v22.12.0")).not.toThrow();
});
it("throws for versions below the minimum", () => {
- expect(() => assertSupportedNodeVersion("v18.17.0")).toThrow(
- /requires Node\.js >= 20\.18\.1/i
+ expect(() => assertSupportedNodeVersion("v18.14.0")).toThrow(
+ /requires Node\.js >= 18\.14\.1/i
);
- expect(() => assertSupportedNodeVersion("v20.18.0")).toThrow(
- /requires Node\.js >= 20\.18\.1/i
+ expect(() => assertSupportedNodeVersion("v18.13.9")).toThrow(
+ /requires Node\.js >= 18\.14\.1/i
);
});
});
diff --git a/cli/services/runtime/node-version-guard.ts b/cli/services/runtime/node-version-guard.ts
index 7b6dc18..31816a8 100644
--- a/cli/services/runtime/node-version-guard.ts
+++ b/cli/services/runtime/node-version-guard.ts
@@ -1,4 +1,4 @@
-const MIN_NODE_VERSION: [number, number, number] = [20, 18, 1];
+const MIN_NODE_VERSION: [number, number, number] = [18, 14, 1];
function parseNodeVersion(version: string): [number, number, number] {
const [major = "0", minor = "0", patch = "0"] = version
diff --git a/docs/aso-runtime-flows.md b/docs/aso-runtime-flows.md
index fcf0217..a32d8f8 100644
--- a/docs/aso-runtime-flows.md
+++ b/docs/aso-runtime-flows.md
@@ -4,7 +4,7 @@
Runtime flow contracts across CLI commands, local dashboard API, and ASO services.
## Runtime Requirement
-- Runtime: Node.js `>=20.18.1`.
+- Runtime: Node.js `>=18.14.1`.
- Local development/build: Node.js `>=20.19.0`.
## Operational Prerequisite
diff --git a/package-lock.json b/package-lock.json
index 8ab85d6..5706a72 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54,7 +54,7 @@
"yaml": "^2.8.2"
},
"engines": {
- "node": ">=20.18.1",
+ "node": ">=18.14.1",
"npm": ">=8.0.0"
}
},
diff --git a/package.json b/package.json
index bd421ab..d7f0949 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
],
"license": "MIT",
"engines": {
- "node": ">=20.18.1",
+ "node": ">=18.14.1",
"npm": ">=8.0.0"
},
"homepage": "https://github.com/semihcihan/App-Store-Optimization-CLI",
From 5960b9b668185742a242ed8f6b4ef831cd24c7d2 Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Thu, 23 Apr 2026 15:58:31 +0300
Subject: [PATCH 02/55] fix bugsnag report
---
scripts/bugsnag-export-events.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/scripts/bugsnag-export-events.sh b/scripts/bugsnag-export-events.sh
index 9c5897c..e5ada6a 100755
--- a/scripts/bugsnag-export-events.sh
+++ b/scripts/bugsnag-export-events.sh
@@ -93,7 +93,7 @@ list_orgs() {
local body headers status
body="$(mktemp)"
headers="$(mktemp)"
- trap 'rm -f "$body" "$headers"' RETURN
+ trap 'rm -f "${body:-}" "${headers:-}"' RETURN
api_get "${BUGSNAG_BASE_URL}/user/organizations" "$body" "$headers"
status="$(http_status_from_headers "$headers")"
@@ -110,7 +110,7 @@ list_projects() {
local body headers status
body="$(mktemp)"
headers="$(mktemp)"
- trap 'rm -f "$body" "$headers"' RETURN
+ trap 'rm -f "${body:-}" "${headers:-}"' RETURN
api_get "${BUGSNAG_BASE_URL}/organizations/${org_id}/projects?per_page=100" "$body" "$headers"
status="$(http_status_from_headers "$headers")"
@@ -182,7 +182,7 @@ export_events() {
while [[ -n "$page_url" ]]; do
body="$(mktemp)"
headers="$(mktemp)"
- trap 'rm -f "$body" "$headers"' RETURN
+ trap 'rm -f "${body:-}" "${headers:-}"' RETURN
api_get "$page_url" "$body" "$headers"
status="$(http_status_from_headers "$headers")"
From ac8e4107732a6e6b28dda93ead413984efae8913 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=B0lker=20G=C3=BCldal=C4=B1?=
Date: Fri, 24 Apr 2026 15:13:33 +0300
Subject: [PATCH 03/55] =?UTF-8?q?feat(backend):=20projects=20workspaces=20?=
=?UTF-8?q?=E2=80=94=20schema=20+=20API=20+=20scoping?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce project workspaces so users can partition their owned apps
and research keyword pools across unrelated categories (e.g. gold
tracker vs halal food scanner).
Data model:
- New `projects (id, name, color, created_at, updated_at)` table.
`id` is a human-readable slug derived from the project name
(Turkish diacritic normalization + collision suffix).
- `owned_apps.project_id` column (idempotent migration, default
`'default'`). FK is enforced at the application layer because
SQLite disallows ADD COLUMN with REFERENCES + NOT NULL default.
- Partial unique index enforces one research app per project.
- Global keyword caches (`aso_keywords`, `aso_apps`,
`aso_keyword_failures`) stay global — shared across projects.
- Idempotent migration of legacy bare `research` app id to
`research:default` (with escape-hatch env var).
DB helpers (`cli/db/projects.ts`):
- `createProject` seeds a per-project research app.
- `deleteProject` guards against removing the last project and
cascades its owned apps + keywords + position history.
- `updateProject` preserves the id across renames (stable for CLI
scripts and bookmarks).
- `listProjectSummaries` returns appCount + keywordCount for UI.
HTTP:
- GET/POST `/api/projects`, PATCH/DELETE `/api/projects/:id`.
- GET/PUT `/api/projects/current` mirrored into `metadata` so CLI
commands and dashboard share a default project.
- `/api/apps` GET + POST scoped by `?projectId=`. Falls back to
the default project when omitted (backward compatible).
- New `PATCH /api/apps/:id {projectId}` to move an app between
projects (keywords follow the app).
- `resolveProjectId` helper validates the query param against the
projects table; unknown id returns 404 PROJECT_NOT_FOUND.
Tests:
- 19 new shared helpers tests (slug + research id helpers).
- 15 new `projects` DB helper tests covering CRUD, cascade, last
project guard, research uniqueness.
- 4 new migration tests covering legacy rename, idempotency,
escape-hatch, fresh install.
- Existing server route tests updated for the new per-project
research id and `projectId` pass-through.
Full suite: 431 tests passing, typecheck clean.
---
cli/dashboard-server/apps-handler.ts | 96 ++++++-
cli/dashboard-server/project-scope.ts | 71 +++++
.../routes/project-handlers.ts | 205 ++++++++++++++
cli/dashboard-server/server.routes.test.ts | 19 +-
cli/dashboard-server/server.ts | 77 +++++-
cli/db/owned-apps.ts | 102 +++++--
cli/db/projects.test.ts | 208 ++++++++++++++
cli/db/projects.ts | 256 ++++++++++++++++++
cli/db/store.migrations.test.ts | 208 ++++++++++++++
cli/db/store.ts | 122 +++++++++
cli/db/types.ts | 1 +
cli/shared/aso-research.test.ts | 44 +++
cli/shared/aso-research.ts | 17 ++
cli/shared/project-types.test.ts | 115 ++++++++
cli/shared/project-types.ts | 113 ++++++++
15 files changed, 1620 insertions(+), 34 deletions(-)
create mode 100644 cli/dashboard-server/project-scope.ts
create mode 100644 cli/dashboard-server/routes/project-handlers.ts
create mode 100644 cli/db/projects.test.ts
create mode 100644 cli/db/projects.ts
create mode 100644 cli/db/store.migrations.test.ts
create mode 100644 cli/shared/aso-research.test.ts
create mode 100644 cli/shared/project-types.test.ts
create mode 100644 cli/shared/project-types.ts
diff --git a/cli/dashboard-server/apps-handler.ts b/cli/dashboard-server/apps-handler.ts
index dde87bc..fcd347a 100644
--- a/cli/dashboard-server/apps-handler.ts
+++ b/cli/dashboard-server/apps-handler.ts
@@ -2,14 +2,18 @@ import * as http from "http";
import {
deleteOwnedAppById,
getOwnedAppById,
+ moveAppToProject,
upsertOwnedApps,
upsertOwnedAppSnapshots,
} from "../db/owned-apps";
+import { getProjectById } from "../db/projects";
import { deleteAppKeywordsByAppId } from "../db/app-keywords";
import {
DEFAULT_RESEARCH_APP_ID,
DEFAULT_RESEARCH_APP_NAME,
+ researchAppIdForProject,
} from "../shared/aso-research";
+import { DEFAULT_PROJECT_ID } from "../shared/project-types";
import type { OwnedAppSnapshot } from "./owned-app-details";
type ManualAppAddRequest =
@@ -82,15 +86,29 @@ function nextResearchAppId(slug: string): string {
}
}
-export function ensureDefaultResearchAppExists(): void {
- if (getOwnedAppById(DEFAULT_RESEARCH_APP_ID)) {
+export function ensureDefaultResearchAppExists(
+ projectId: string = DEFAULT_PROJECT_ID
+): void {
+ const researchId =
+ projectId === DEFAULT_PROJECT_ID
+ ? researchAppIdForProject(DEFAULT_PROJECT_ID)
+ : researchAppIdForProject(projectId);
+ if (getOwnedAppById(researchId)) {
+ return;
+ }
+ // Backfill: if legacy bare "research" row exists (pre-migration), skip seed.
+ if (
+ projectId === DEFAULT_PROJECT_ID &&
+ getOwnedAppById(DEFAULT_RESEARCH_APP_ID)
+ ) {
return;
}
upsertOwnedApps([
{
- id: DEFAULT_RESEARCH_APP_ID,
+ id: researchId,
kind: "research",
name: DEFAULT_RESEARCH_APP_NAME,
+ projectId,
},
]);
}
@@ -98,7 +116,8 @@ export function ensureDefaultResearchAppExists(): void {
export function createAppsHandlers(deps: CreateAppsHandlersDeps) {
async function handleApiAppsPost(
req: http.IncomingMessage,
- res: http.ServerResponse
+ res: http.ServerResponse,
+ projectId: string = DEFAULT_PROJECT_ID
): Promise {
const body = await deps.parseJsonBody(req, res);
if (!body) {
@@ -123,12 +142,15 @@ export function createAppsHandlers(deps: CreateAppsHandlersDeps) {
}
const country = deps.hydrationCountry;
- upsertOwnedApps([{ id: appId, kind: "owned", name: appId }]);
+ upsertOwnedApps([{ id: appId, kind: "owned", name: appId, projectId }]);
let hydratedName = appId;
try {
const snapshots = await deps.fetchOwnedAppSnapshotsFromApi(country, [appId]);
if (snapshots.length > 0) {
- upsertOwnedAppSnapshots(country, snapshots);
+ upsertOwnedAppSnapshots(
+ country,
+ snapshots.map((snapshot) => ({ ...snapshot, projectId }))
+ );
const first = snapshots[0];
if (first?.name?.trim()) {
hydratedName = first.name.trim();
@@ -141,6 +163,7 @@ export function createAppsHandlers(deps: CreateAppsHandlersDeps) {
phase: "manual-app-hydration",
appId,
country,
+ projectId,
});
}
@@ -161,9 +184,9 @@ export function createAppsHandlers(deps: CreateAppsHandlersDeps) {
}
const slug = slugifyResearchName(name);
- ensureDefaultResearchAppExists();
+ ensureDefaultResearchAppExists(projectId);
const id = nextResearchAppId(slug);
- upsertOwnedApps([{ id, kind: "research", name }]);
+ upsertOwnedApps([{ id, kind: "research", name, projectId }]);
deps.sendJson(res, 201, {
success: true,
data: {
@@ -220,8 +243,65 @@ export function createAppsHandlers(deps: CreateAppsHandlersDeps) {
});
}
+ async function handleApiAppPatch(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ appId: string
+ ): Promise {
+ const body = await deps.parseJsonBody<{ projectId?: string }>(req, res);
+ if (!body) return;
+ const normalized = normalizeAppId(appId);
+ if (!normalized) {
+ deps.sendApiError(res, 400, "INVALID_REQUEST", "App ID is required.");
+ return;
+ }
+ const targetProjectId =
+ typeof body.projectId === "string" ? body.projectId.trim() : "";
+ if (!targetProjectId) {
+ deps.sendApiError(
+ res,
+ 400,
+ "INVALID_REQUEST",
+ "projectId is required in the request body."
+ );
+ return;
+ }
+ const project = getProjectById(targetProjectId);
+ if (!project) {
+ deps.sendApiError(res, 404, "PROJECT_NOT_FOUND", "Project not found.");
+ return;
+ }
+ const existing = getOwnedAppById(normalized, deps.hydrationCountry);
+ if (!existing) {
+ deps.sendApiError(res, 404, "NOT_FOUND", "App not found.");
+ return;
+ }
+ if (existing.kind === "research") {
+ deps.sendApiError(
+ res,
+ 400,
+ "INVALID_REQUEST",
+ "Research apps cannot be moved between projects."
+ );
+ return;
+ }
+ const moved = moveAppToProject(normalized, project.id);
+ if (!moved) {
+ deps.sendApiError(res, 404, "NOT_FOUND", "App not found.");
+ return;
+ }
+ deps.sendJson(res, 200, {
+ success: true,
+ data: {
+ id: normalized,
+ projectId: project.id,
+ },
+ });
+ }
+
return {
handleApiAppsPost,
handleApiAppsDelete,
+ handleApiAppPatch,
};
}
diff --git a/cli/dashboard-server/project-scope.ts b/cli/dashboard-server/project-scope.ts
new file mode 100644
index 0000000..5c1791b
--- /dev/null
+++ b/cli/dashboard-server/project-scope.ts
@@ -0,0 +1,71 @@
+import * as http from "http";
+import { getProjectById } from "../db/projects";
+import { setMetadataValue } from "../db/metadata";
+import { DEFAULT_PROJECT_ID } from "../shared/project-types";
+
+export function resolveProjectId(
+ query: Record,
+ res: http.ServerResponse,
+ sendApiError: (
+ res: http.ServerResponse,
+ status: number,
+ errorCode: string,
+ message: string
+ ) => void
+): string | null {
+ const raw = typeof query.projectId === "string" ? query.projectId.trim() : "";
+ const explicit = raw.length > 0;
+ const candidate = explicit ? raw : DEFAULT_PROJECT_ID;
+ let project: { id: string } | null = null;
+ try {
+ project = getProjectById(candidate);
+ } catch {
+ project = null;
+ }
+ if (!project) {
+ if (explicit) {
+ sendApiError(
+ res,
+ 404,
+ "PROJECT_NOT_FOUND",
+ `Project "${candidate}" does not exist.`
+ );
+ return null;
+ }
+ // No explicit projectId and no default row available (e.g. mocked test DB).
+ return DEFAULT_PROJECT_ID;
+ }
+ try {
+ setMetadataValue("current_project_id", project.id);
+ } catch {}
+ return project.id;
+}
+
+export function resolveProjectIdFromBody(
+ body: { projectId?: unknown } | null | undefined,
+ res: http.ServerResponse,
+ sendApiError: (
+ res: http.ServerResponse,
+ status: number,
+ errorCode: string,
+ message: string
+ ) => void
+): string | null {
+ const raw =
+ body && typeof body.projectId === "string" ? body.projectId.trim() : "";
+ const candidate = raw.length > 0 ? raw : DEFAULT_PROJECT_ID;
+ const project = getProjectById(candidate);
+ if (!project) {
+ sendApiError(
+ res,
+ 404,
+ "PROJECT_NOT_FOUND",
+ `Project "${candidate}" does not exist.`
+ );
+ return null;
+ }
+ try {
+ setMetadataValue("current_project_id", project.id);
+ } catch {}
+ return project.id;
+}
diff --git a/cli/dashboard-server/routes/project-handlers.ts b/cli/dashboard-server/routes/project-handlers.ts
new file mode 100644
index 0000000..60587ae
--- /dev/null
+++ b/cli/dashboard-server/routes/project-handlers.ts
@@ -0,0 +1,205 @@
+import * as http from "http";
+import {
+ ProjectError,
+ createProject,
+ deleteProject,
+ getProjectById,
+ listProjectSummaries,
+ updateProject,
+} from "../../db/projects";
+import { getMetadataValue, setMetadataValue } from "../../db/metadata";
+import {
+ DEFAULT_PROJECT_ID,
+ isValidProjectColor,
+ type ProjectColor,
+} from "../../shared/project-types";
+
+type ProjectHandlersDeps = {
+ parseJsonBody: (
+ req: http.IncomingMessage,
+ res: http.ServerResponse
+ ) => Promise;
+ sendJson: (res: http.ServerResponse, status: number, data: unknown) => void;
+ sendApiError: (
+ res: http.ServerResponse,
+ status: number,
+ errorCode: string,
+ message: string
+ ) => void;
+ reportDashboardError: (
+ error: unknown,
+ metadata: Record
+ ) => void;
+};
+
+function handleProjectError(
+ res: http.ServerResponse,
+ deps: ProjectHandlersDeps,
+ error: unknown
+): void {
+ if (error instanceof ProjectError) {
+ const status =
+ error.code === "LAST_PROJECT"
+ ? 409
+ : error.code === "DUPLICATE_NAME"
+ ? 409
+ : 400;
+ deps.sendApiError(res, status, error.code, error.message);
+ return;
+ }
+ deps.reportDashboardError(error, {
+ source: "project-handlers",
+ });
+ deps.sendApiError(
+ res,
+ 500,
+ "INTERNAL_ERROR",
+ "An unexpected error occurred while processing projects."
+ );
+}
+
+export function createProjectHandlers(deps: ProjectHandlersDeps) {
+ function handleProjectsGet(res: http.ServerResponse): void {
+ try {
+ const summaries = listProjectSummaries();
+ deps.sendJson(res, 200, {
+ success: true,
+ data: summaries,
+ });
+ } catch (error) {
+ handleProjectError(res, deps, error);
+ }
+ }
+
+ async function handleProjectsPost(
+ req: http.IncomingMessage,
+ res: http.ServerResponse
+ ): Promise {
+ const body = await deps.parseJsonBody<{
+ name?: string;
+ color?: string;
+ }>(req, res);
+ if (!body) return;
+ if (typeof body.name !== "string") {
+ deps.sendApiError(
+ res,
+ 400,
+ "INVALID_REQUEST",
+ "Project name is required."
+ );
+ return;
+ }
+ const color: ProjectColor | undefined = isValidProjectColor(body.color)
+ ? body.color
+ : undefined;
+ try {
+ const project = createProject({ name: body.name, color });
+ deps.sendJson(res, 201, { success: true, data: project });
+ } catch (error) {
+ handleProjectError(res, deps, error);
+ }
+ }
+
+ async function handleProjectPatch(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ id: string
+ ): Promise {
+ const body = await deps.parseJsonBody<{
+ name?: string;
+ color?: string;
+ }>(req, res);
+ if (!body) return;
+ const color: ProjectColor | undefined = isValidProjectColor(body.color)
+ ? body.color
+ : undefined;
+ try {
+ const updated = updateProject(id, {
+ name: typeof body.name === "string" ? body.name : undefined,
+ color,
+ });
+ if (!updated) {
+ deps.sendApiError(res, 404, "PROJECT_NOT_FOUND", "Project not found.");
+ return;
+ }
+ deps.sendJson(res, 200, { success: true, data: updated });
+ } catch (error) {
+ handleProjectError(res, deps, error);
+ }
+ }
+
+ function handleProjectDelete(res: http.ServerResponse, id: string): void {
+ try {
+ const result = deleteProject(id);
+ if (!result) {
+ deps.sendApiError(res, 404, "PROJECT_NOT_FOUND", "Project not found.");
+ return;
+ }
+ const current = getMetadataValue("current_project_id");
+ if (current === id) {
+ setMetadataValue("current_project_id", DEFAULT_PROJECT_ID);
+ }
+ deps.sendJson(res, 200, { success: true, data: result });
+ } catch (error) {
+ handleProjectError(res, deps, error);
+ }
+ }
+
+ function handleCurrentProjectGet(res: http.ServerResponse): void {
+ const stored = getMetadataValue("current_project_id");
+ const project =
+ (stored ? getProjectById(stored) : null) ??
+ getProjectById(DEFAULT_PROJECT_ID);
+ if (!project) {
+ deps.sendApiError(
+ res,
+ 404,
+ "PROJECT_NOT_FOUND",
+ "No project is available."
+ );
+ return;
+ }
+ deps.sendJson(res, 200, {
+ success: true,
+ data: { projectId: project.id },
+ });
+ }
+
+ async function handleCurrentProjectPut(
+ req: http.IncomingMessage,
+ res: http.ServerResponse
+ ): Promise {
+ const body = await deps.parseJsonBody<{ projectId?: string }>(req, res);
+ if (!body) return;
+ const projectId =
+ typeof body.projectId === "string" ? body.projectId.trim() : "";
+ if (!projectId) {
+ deps.sendApiError(
+ res,
+ 400,
+ "INVALID_REQUEST",
+ "projectId is required."
+ );
+ return;
+ }
+ const project = getProjectById(projectId);
+ if (!project) {
+ deps.sendApiError(res, 404, "PROJECT_NOT_FOUND", "Project not found.");
+ return;
+ }
+ setMetadataValue("current_project_id", project.id);
+ deps.sendJson(res, 200, {
+ success: true,
+ data: { projectId: project.id },
+ });
+ }
+
+ return {
+ handleProjectsGet,
+ handleProjectsPost,
+ handleProjectPatch,
+ handleProjectDelete,
+ handleCurrentProjectGet,
+ handleCurrentProjectPut,
+ };
+}
diff --git a/cli/dashboard-server/server.routes.test.ts b/cli/dashboard-server/server.routes.test.ts
index 91aa61d..348a4e9 100644
--- a/cli/dashboard-server/server.routes.test.ts
+++ b/cli/dashboard-server/server.routes.test.ts
@@ -357,7 +357,12 @@ describe("dashboard server routes", () => {
expect(response.statusCode).toBe(200);
expect(mockUpsertOwnedApps).toHaveBeenCalledWith([
- { id: DEFAULT_RESEARCH_APP_ID, kind: "research", name: "Research" },
+ {
+ id: "research:default",
+ kind: "research",
+ name: "Research",
+ projectId: "default",
+ },
]);
expect(response.json?.data.map((item: any) => item.id)).toEqual(["2", "1"]);
});
@@ -450,7 +455,7 @@ describe("dashboard server routes", () => {
it("creates research app with slug collision suffix", async () => {
mockGetOwnedAppById.mockImplementation((id: string) => {
- if (id === DEFAULT_RESEARCH_APP_ID) return { id, name: "Research" } as any;
+ if (id === "research:default") return { id, name: "Research" } as any;
if (id === "research:my-ideas") return { id, name: "My Ideas" } as any;
return null;
});
@@ -470,7 +475,12 @@ describe("dashboard server routes", () => {
},
});
expect(mockUpsertOwnedApps).toHaveBeenCalledWith([
- { id: "research:my-ideas-2", kind: "research", name: "My Ideas" },
+ {
+ id: "research:my-ideas-2",
+ kind: "research",
+ name: "My Ideas",
+ projectId: "default",
+ },
]);
});
@@ -500,7 +510,7 @@ describe("dashboard server routes", () => {
},
});
expect(mockUpsertOwnedApps).toHaveBeenCalledWith([
- { id: "123", kind: "owned", name: "123" },
+ { id: "123", kind: "owned", name: "123", projectId: "default" },
]);
expect(mockUpsertOwnedAppSnapshots).toHaveBeenCalledWith("US", [
{
@@ -509,6 +519,7 @@ describe("dashboard server routes", () => {
averageUserRating: 4.4,
userRatingCount: 100,
expiresAt: "2099-01-01T00:00:00.000Z",
+ projectId: "default",
},
]);
});
diff --git a/cli/dashboard-server/server.ts b/cli/dashboard-server/server.ts
index 79ff679..f404e40 100644
--- a/cli/dashboard-server/server.ts
+++ b/cli/dashboard-server/server.ts
@@ -33,6 +33,8 @@ import {
import {
createAsoRouteHandlers,
} from "./routes/aso-routes";
+import { createProjectHandlers } from "./routes/project-handlers";
+import { resolveProjectId } from "./project-scope";
import { sendApiError, sendJson, parseJsonBody } from "./http-utils";
import {
resolveStaticPath,
@@ -238,6 +240,13 @@ const asoRouteHandlers = createAsoRouteHandlers({
isTruthyQueryParam,
});
+const projectHandlers = createProjectHandlers({
+ parseJsonBody,
+ sendJson,
+ sendApiError,
+ reportDashboardError,
+});
+
export function createServerRequestHandler(): http.RequestListener {
return async (req, res) => {
const url = req.url ?? "/";
@@ -255,6 +264,50 @@ export function createServerRequestHandler(): http.RequestListener {
return;
}
+ if (req.method === "GET" && pathname === "/api/projects") {
+ projectHandlers.handleProjectsGet(res);
+ return;
+ }
+
+ if (req.method === "POST" && pathname === "/api/projects") {
+ await runAsForegroundMutation(() =>
+ projectHandlers.handleProjectsPost(req, res)
+ );
+ return;
+ }
+
+ if (req.method === "GET" && pathname === "/api/projects/current") {
+ projectHandlers.handleCurrentProjectGet(res);
+ return;
+ }
+
+ if (req.method === "PUT" && pathname === "/api/projects/current") {
+ await runAsForegroundMutation(() =>
+ projectHandlers.handleCurrentProjectPut(req, res)
+ );
+ return;
+ }
+
+ if (
+ pathname.startsWith("/api/projects/") &&
+ pathname !== "/api/projects/current"
+ ) {
+ const projectId = pathname.slice("/api/projects/".length);
+ if (req.method === "PATCH") {
+ await runAsForegroundMutation(() =>
+ projectHandlers.handleProjectPatch(req, res, projectId)
+ );
+ return;
+ }
+ if (req.method === "DELETE") {
+ await runAsForegroundMutation(() => {
+ projectHandlers.handleProjectDelete(res, projectId);
+ return Promise.resolve();
+ });
+ return;
+ }
+ }
+
if (req.method === "GET" && pathname === DASHBOARD_RUNTIME_CONFIG_PATH) {
sendDashboardRuntimeConfig(
res,
@@ -266,8 +319,10 @@ export function createServerRequestHandler(): http.RequestListener {
}
if (req.method === "GET" && pathname === "/api/apps") {
- ensureDefaultResearchAppExists();
- let apps = listOwnedApps(DEFAULT_APP_DOCS_HYDRATION_COUNTRY);
+ const projectId = resolveProjectId(query, res, sendApiError);
+ if (!projectId) return;
+ ensureDefaultResearchAppExists(projectId);
+ let apps = listOwnedApps(DEFAULT_APP_DOCS_HYDRATION_COUNTRY, projectId);
const nowMs = Date.now();
const refreshMaxAgeMs = ASO_ENV.ownedAppDocRefreshMaxAgeMs;
const staleOwnedAppIds = apps
@@ -290,7 +345,7 @@ export function createServerRequestHandler(): http.RequestListener {
if (snapshots.length > 0) {
upsertOwnedAppSnapshots(DEFAULT_APP_DOCS_HYDRATION_COUNTRY, snapshots);
}
- apps = listOwnedApps(DEFAULT_APP_DOCS_HYDRATION_COUNTRY);
+ apps = listOwnedApps(DEFAULT_APP_DOCS_HYDRATION_COUNTRY, projectId);
} catch (error) {
reportDashboardError(error, {
method: "GET",
@@ -328,8 +383,10 @@ export function createServerRequestHandler(): http.RequestListener {
}
if (req.method === "POST" && pathname === "/api/apps") {
+ const projectId = resolveProjectId(query, res, sendApiError);
+ if (!projectId) return;
await runAsForegroundMutation(() =>
- appsHandlers.handleApiAppsPost(req, res)
+ appsHandlers.handleApiAppsPost(req, res, projectId)
);
return;
}
@@ -341,6 +398,18 @@ export function createServerRequestHandler(): http.RequestListener {
return;
}
+ if (
+ req.method === "PATCH" &&
+ pathname.startsWith("/api/apps/") &&
+ pathname !== "/api/apps/"
+ ) {
+ const appId = pathname.slice("/api/apps/".length);
+ await runAsForegroundMutation(() =>
+ appsHandlers.handleApiAppPatch(req, res, appId)
+ );
+ return;
+ }
+
if (req.method === "GET" && pathname === "/api/aso/auth/status") {
handleApiAsoAuthStatusGet(res);
return;
diff --git a/cli/db/owned-apps.ts b/cli/db/owned-apps.ts
index 12583dc..0ec2865 100644
--- a/cli/db/owned-apps.ts
+++ b/cli/db/owned-apps.ts
@@ -1,12 +1,14 @@
import { DEFAULT_ASO_COUNTRY, normalizeCountry } from "../domain/keywords/policy";
import type { OwnedAppKind, StoredOwnedApp } from "./types";
import { getDb } from "./store";
+import { DEFAULT_PROJECT_ID } from "../shared/project-types";
type OwnedAppRow = {
id: string;
kind: string;
name: string;
icon_json: string | null;
+ project_id: string;
average_user_rating: number | null;
user_rating_count: number | null;
previous_average_user_rating: number | null;
@@ -46,6 +48,7 @@ function toStoredOwnedApp(row: OwnedAppRow): StoredOwnedApp {
id: row.id,
kind: toOwnedAppKind(row.kind),
name: row.name,
+ projectId: row.project_id ?? DEFAULT_PROJECT_ID,
averageUserRating: row.average_user_rating,
userRatingCount: row.user_rating_count,
previousAverageUserRating: row.previous_average_user_rating,
@@ -56,46 +59,71 @@ function toStoredOwnedApp(row: OwnedAppRow): StoredOwnedApp {
};
}
-export function listOwnedApps(country: string = DEFAULT_ASO_COUNTRY): StoredOwnedApp[] {
+export function listOwnedApps(
+ country: string = DEFAULT_ASO_COUNTRY,
+ projectId?: string
+): StoredOwnedApp[] {
const db = getDb();
const normalizedCountry = normalizeCountry(country);
+ const params: unknown[] = [normalizedCountry];
+ let whereClause = "";
+ if (projectId) {
+ whereClause = " WHERE app.project_id = ?";
+ params.push(projectId);
+ }
const rows = db
.prepare(
- `SELECT app.id, app.kind, app.name, app.icon_json,
+ `SELECT app.id, app.kind, app.name, app.icon_json, app.project_id,
ratings.average_user_rating, ratings.user_rating_count,
ratings.previous_average_user_rating, ratings.previous_user_rating_count,
ratings.expires_at, ratings.last_fetched_at
FROM owned_apps app
LEFT JOIN owned_app_country_ratings ratings
ON ratings.app_id = app.id
- AND ratings.country = ?
+ AND ratings.country = ?${whereClause}
ORDER BY app.name COLLATE NOCASE ASC`
)
- .all(normalizedCountry) as OwnedAppRow[];
+ .all(...params) as OwnedAppRow[];
return rows.map(toStoredOwnedApp);
}
-export function listOwnedAppIdsByKind(kind: OwnedAppKind): string[] {
+export function listOwnedAppIdsByKind(
+ kind: OwnedAppKind,
+ projectId?: string
+): string[] {
const db = getDb();
+ const params: unknown[] = [kind];
+ let whereClause = "";
+ if (projectId) {
+ whereClause = " AND project_id = ?";
+ params.push(projectId);
+ }
const rows = db
.prepare(
`SELECT id
FROM owned_apps
- WHERE kind = ?`
+ WHERE kind = ?${whereClause}`
)
- .all(kind) as Array<{ id: string }>;
+ .all(...params) as Array<{ id: string }>;
return rows.map((row) => row.id);
}
export function getOwnedAppById(
id: string,
- country: string = DEFAULT_ASO_COUNTRY
+ country: string = DEFAULT_ASO_COUNTRY,
+ projectId?: string
): StoredOwnedApp | null {
const db = getDb();
const normalizedCountry = normalizeCountry(country);
+ const params: unknown[] = [normalizedCountry, id];
+ let whereClause = "WHERE app.id = ?";
+ if (projectId) {
+ whereClause += " AND app.project_id = ?";
+ params.push(projectId);
+ }
const row = db
.prepare(
- `SELECT app.id, app.kind, app.name, app.icon_json,
+ `SELECT app.id, app.kind, app.name, app.icon_json, app.project_id,
ratings.average_user_rating, ratings.user_rating_count,
ratings.previous_average_user_rating, ratings.previous_user_rating_count,
ratings.expires_at, ratings.last_fetched_at
@@ -103,20 +131,44 @@ export function getOwnedAppById(
LEFT JOIN owned_app_country_ratings ratings
ON ratings.app_id = app.id
AND ratings.country = ?
- WHERE app.id = ?`
+ ${whereClause}`
)
- .get(normalizedCountry, id) as OwnedAppRow | undefined;
+ .get(...params) as OwnedAppRow | undefined;
return row ? toStoredOwnedApp(row) : null;
}
+export function getAppProjectId(id: string): string | null {
+ const db = getDb();
+ const row = db
+ .prepare(`SELECT project_id FROM owned_apps WHERE id = ?`)
+ .get(id) as { project_id: string } | undefined;
+ return row?.project_id ?? null;
+}
+
+export function moveAppToProject(
+ id: string,
+ projectId: string
+): boolean {
+ const db = getDb();
+ const result = db
+ .prepare(`UPDATE owned_apps SET project_id = ? WHERE id = ?`)
+ .run(projectId, id);
+ return result.changes > 0;
+}
+
export function upsertOwnedApps(
- apps: Array<{ id: string; kind: OwnedAppKind; name: string }>
+ apps: Array<{
+ id: string;
+ kind: OwnedAppKind;
+ name: string;
+ projectId?: string;
+ }>
): void {
if (apps.length === 0) return;
const db = getDb();
const stmt = db.prepare(
- `INSERT INTO owned_apps (id, kind, name)
- VALUES (@id, @kind, @name)
+ `INSERT INTO owned_apps (id, kind, name, project_id)
+ VALUES (@id, @kind, @name, @projectId)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
name = excluded.name`
@@ -128,6 +180,7 @@ export function upsertOwnedApps(
id: app.id,
kind: app.kind,
name: app.name,
+ projectId: app.projectId ?? DEFAULT_PROJECT_ID,
});
}
});
@@ -139,6 +192,7 @@ export function upsertOwnedAppSnapshots(
apps: Array<{
id: string;
name?: string;
+ projectId?: string;
averageUserRating?: number | null;
userRatingCount?: number | null;
icon?: Record;
@@ -150,7 +204,7 @@ export function upsertOwnedAppSnapshots(
const db = getDb();
const normalizedCountry = normalizeCountry(country);
const selectOwnedStmt = db.prepare(
- `SELECT id, kind, name, icon_json
+ `SELECT id, kind, name, icon_json, project_id
FROM owned_apps
WHERE id = ?`
);
@@ -162,8 +216,8 @@ export function upsertOwnedAppSnapshots(
WHERE app_id = ? AND country = ?`
);
const upsertOwnedStmt = db.prepare(
- `INSERT INTO owned_apps (id, kind, name, icon_json)
- VALUES (@id, @kind, @name, @iconJson)
+ `INSERT INTO owned_apps (id, kind, name, icon_json, project_id)
+ VALUES (@id, @kind, @name, @iconJson, @projectId)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
name = excluded.name,
@@ -194,7 +248,13 @@ export function upsertOwnedAppSnapshots(
const tx = db.transaction(() => {
for (const app of apps) {
const existingOwned = selectOwnedStmt.get(app.id) as
- | { id: string; kind: string; name: string; icon_json: string | null }
+ | {
+ id: string;
+ kind: string;
+ name: string;
+ icon_json: string | null;
+ project_id: string | null;
+ }
| undefined;
const existingRating = selectRatingStmt.get(
app.id,
@@ -227,11 +287,17 @@ export function upsertOwnedAppSnapshots(
const icon =
app.icon ?? parseJsonObject(existingOwned?.icon_json ?? null) ?? undefined;
+ const projectId =
+ app.projectId ??
+ existingOwned?.project_id ??
+ DEFAULT_PROJECT_ID;
+
upsertOwnedStmt.run({
id: app.id,
kind,
name: nextName,
iconJson: icon ? JSON.stringify(icon) : null,
+ projectId,
});
upsertRatingsStmt.run({
diff --git a/cli/db/projects.test.ts b/cli/db/projects.test.ts
new file mode 100644
index 0000000..aa0b4b5
--- /dev/null
+++ b/cli/db/projects.test.ts
@@ -0,0 +1,208 @@
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import { closeDbForTests, getDb } from "./store";
+import {
+ ProjectError,
+ createProject,
+ deleteProject,
+ getProjectById,
+ getProjectByName,
+ getProjectCounts,
+ listProjects,
+ listProjectSummaries,
+ updateProject,
+} from "./projects";
+import { DEFAULT_PROJECT_ID } from "../shared/project-types";
+import { researchAppIdForProject } from "../shared/aso-research";
+
+const TEST_DB_PATH = path.join(
+ os.tmpdir(),
+ `aso-projects-${process.pid}-${Date.now()}.sqlite`
+);
+
+function cleanDbFiles(): void {
+ for (const suffix of ["", "-wal", "-shm"]) {
+ try {
+ fs.unlinkSync(`${TEST_DB_PATH}${suffix}`);
+ } catch {}
+ }
+}
+
+describe("projects", () => {
+ beforeAll(() => {
+ process.env.ASO_DB_PATH = TEST_DB_PATH;
+ });
+
+ beforeEach(() => {
+ closeDbForTests();
+ cleanDbFiles();
+ });
+
+ afterAll(() => {
+ closeDbForTests();
+ cleanDbFiles();
+ delete process.env.ASO_DB_PATH;
+ });
+
+ it("returns the default project on fresh init", () => {
+ const projects = listProjects();
+ expect(projects).toHaveLength(1);
+ expect(projects[0].id).toBe(DEFAULT_PROJECT_ID);
+ expect(projects[0].name).toBe("Default");
+ });
+
+ it("creates a project with slug id and seeds a research app", () => {
+ const created = createProject({ name: "Halal Food", color: "green" });
+ expect(created.id).toBe("halal-food");
+ expect(created.name).toBe("Halal Food");
+ expect(created.color).toBe("green");
+
+ const db = getDb();
+ const researchRow = db
+ .prepare(
+ `SELECT id, kind, project_id FROM owned_apps WHERE project_id = ? AND kind = 'research'`
+ )
+ .get(created.id) as
+ | { id: string; kind: string; project_id: string }
+ | undefined;
+ expect(researchRow).toBeDefined();
+ expect(researchRow?.id).toBe(researchAppIdForProject(created.id));
+ });
+
+ it("normalizes Turkish characters in slug", () => {
+ const created = createProject({ name: "Altın Takibi" });
+ expect(created.id).toBe("altin-takibi");
+ });
+
+ it("appends suffix when two different names slugify to the same value", () => {
+ const first = createProject({ name: "Food Scanner" });
+ // "food-scanner" as a display name slugifies to the same "food-scanner" id,
+ // but passes the case-insensitive name uniqueness check.
+ const second = createProject({ name: "food-scanner" });
+ expect(first.id).toBe("food-scanner");
+ expect(second.id).toBe("food-scanner-2");
+ });
+
+ it("rejects duplicate names case-insensitively", () => {
+ createProject({ name: "Gold Tracker" });
+ expect(() => createProject({ name: "gold tracker" })).toThrow(ProjectError);
+ expect(() => createProject({ name: "GOLD TRACKER" })).toThrow(ProjectError);
+ });
+
+ it("rejects invalid or empty names", () => {
+ expect(() => createProject({ name: "" })).toThrow(ProjectError);
+ expect(() => createProject({ name: " " })).toThrow(ProjectError);
+ expect(() => createProject({ name: "x".repeat(61) })).toThrow(ProjectError);
+ });
+
+ it("updates project name and color but preserves id", () => {
+ const created = createProject({ name: "Draft" });
+ const updated = updateProject(created.id, {
+ name: "Published",
+ color: "blue",
+ });
+ expect(updated?.id).toBe(created.id);
+ expect(updated?.name).toBe("Published");
+ expect(updated?.color).toBe("blue");
+ });
+
+ it("rejects renaming to an existing project name", () => {
+ const a = createProject({ name: "Alpha" });
+ createProject({ name: "Beta" });
+ expect(() => updateProject(a.id, { name: "Beta" })).toThrow(ProjectError);
+ });
+
+ it("allows renaming to the same name (case-insensitive no-op)", () => {
+ const created = createProject({ name: "Alpha" });
+ const updated = updateProject(created.id, { name: "ALPHA" });
+ expect(updated?.name).toBe("ALPHA");
+ });
+
+ it("refuses to delete the only project", () => {
+ expect(() => deleteProject(DEFAULT_PROJECT_ID)).toThrow(ProjectError);
+ });
+
+ it("deletes a project and cascades its owned apps + keywords", () => {
+ const project = createProject({ name: "Temp" });
+ const db = getDb();
+ db.prepare(
+ `INSERT INTO owned_apps (id, kind, name, icon_json, project_id)
+ VALUES (?, 'owned', ?, NULL, ?)`
+ ).run("11111", "Temp App", project.id);
+ db.prepare(
+ `INSERT INTO app_keywords (app_id, keyword, country, is_favorite, previous_position, added_at)
+ VALUES (?, ?, 'US', 0, NULL, ?)`
+ ).run("11111", "temp kw", new Date().toISOString());
+ db.prepare(
+ `INSERT INTO app_keyword_position_history (app_id, keyword, country, position, captured_at)
+ VALUES (?, ?, 'US', ?, ?)`
+ ).run("11111", "temp kw", 10, new Date().toISOString());
+
+ const counts = getProjectCounts(project.id);
+ expect(counts.appCount).toBe(1);
+ expect(counts.keywordCount).toBe(1);
+
+ const result = deleteProject(project.id);
+ expect(result?.deletedAppCount).toBe(1);
+ expect(result?.deletedKeywordCount).toBe(1);
+
+ expect(getProjectById(project.id)).toBeNull();
+ const ownedAfter = db
+ .prepare(`SELECT id FROM owned_apps WHERE project_id = ?`)
+ .all(project.id);
+ expect(ownedAfter).toHaveLength(0);
+ const keywordsAfter = db
+ .prepare(`SELECT keyword FROM app_keywords WHERE app_id = ?`)
+ .all("11111");
+ expect(keywordsAfter).toHaveLength(0);
+ const historyAfter = db
+ .prepare(
+ `SELECT keyword FROM app_keyword_position_history WHERE app_id = ?`
+ )
+ .all("11111");
+ expect(historyAfter).toHaveLength(0);
+ });
+
+ it("returns null from delete for unknown project id", () => {
+ expect(deleteProject("does-not-exist")).toBeNull();
+ });
+
+ it("provides summaries with counts", () => {
+ const project = createProject({ name: "With Data" });
+ const db = getDb();
+ db.prepare(
+ `INSERT INTO owned_apps (id, kind, name, icon_json, project_id)
+ VALUES (?, 'owned', ?, NULL, ?)`
+ ).run("22222", "Data App", project.id);
+ db.prepare(
+ `INSERT INTO app_keywords (app_id, keyword, country, is_favorite, previous_position, added_at)
+ VALUES (?, ?, 'US', 0, NULL, ?)`
+ ).run("22222", "demo", new Date().toISOString());
+
+ const summaries = listProjectSummaries();
+ const withData = summaries.find((summary) => summary.id === project.id);
+ expect(withData?.appCount).toBe(1);
+ expect(withData?.keywordCount).toBe(1);
+ });
+
+ it("looks up projects by name case-insensitively", () => {
+ const created = createProject({ name: "Mixed" });
+ expect(getProjectByName("mixed")?.id).toBe(created.id);
+ expect(getProjectByName(" MIXED ")?.id).toBe(created.id);
+ expect(getProjectByName("absent")).toBeNull();
+ });
+
+ it("enforces uniqueness of research app per project via index", () => {
+ const project = createProject({ name: "Unique Research" });
+ const db = getDb();
+ expect(() =>
+ db
+ .prepare(
+ `INSERT INTO owned_apps (id, kind, name, icon_json, project_id)
+ VALUES (?, 'research', ?, NULL, ?)`
+ )
+ .run(`research:${project.id}-extra`, "Extra", project.id)
+ ).toThrow();
+ });
+});
diff --git a/cli/db/projects.ts b/cli/db/projects.ts
new file mode 100644
index 0000000..ad1a1e0
--- /dev/null
+++ b/cli/db/projects.ts
@@ -0,0 +1,256 @@
+import { getDb } from "./store";
+import {
+ DEFAULT_PROJECT_COLOR,
+ DEFAULT_PROJECT_ID,
+ PROJECT_COLORS,
+ ensureUniqueSlug,
+ slugifyProjectName,
+ validateProjectName,
+ type Project,
+ type ProjectColor,
+ type ProjectSummary,
+} from "../shared/project-types";
+import {
+ DEFAULT_RESEARCH_APP_NAME,
+ researchAppIdForProject,
+} from "../shared/aso-research";
+
+type ProjectRow = {
+ id: string;
+ name: string;
+ color: string;
+ created_at: string;
+ updated_at: string;
+};
+
+function toProject(row: ProjectRow): Project {
+ const color = (PROJECT_COLORS as readonly string[]).includes(row.color)
+ ? (row.color as ProjectColor)
+ : DEFAULT_PROJECT_COLOR;
+ return {
+ id: row.id,
+ name: row.name,
+ color,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export class ProjectError extends Error {
+ code: string;
+ constructor(code: string, message: string) {
+ super(message);
+ this.name = "ProjectError";
+ this.code = code;
+ }
+}
+
+export function listProjects(): Project[] {
+ const db = getDb();
+ const rows = db
+ .prepare(
+ `SELECT id, name, color, created_at, updated_at
+ FROM projects
+ ORDER BY created_at ASC, id ASC`
+ )
+ .all() as ProjectRow[];
+ return rows.map(toProject);
+}
+
+export function listProjectSummaries(): ProjectSummary[] {
+ const db = getDb();
+ const rows = db
+ .prepare(
+ `SELECT
+ p.id,
+ p.name,
+ p.color,
+ p.created_at,
+ p.updated_at,
+ (SELECT COUNT(*) FROM owned_apps oa
+ WHERE oa.project_id = p.id AND oa.kind = 'owned') AS app_count,
+ (SELECT COUNT(*) FROM app_keywords ak
+ JOIN owned_apps oa2 ON oa2.id = ak.app_id
+ WHERE oa2.project_id = p.id) AS keyword_count
+ FROM projects p
+ ORDER BY p.created_at ASC, p.id ASC`
+ )
+ .all() as Array;
+ return rows.map((row) => ({
+ ...toProject(row),
+ appCount: Number(row.app_count ?? 0),
+ keywordCount: Number(row.keyword_count ?? 0),
+ }));
+}
+
+export function getProjectById(id: string): Project | null {
+ const db = getDb();
+ const row = db
+ .prepare(
+ `SELECT id, name, color, created_at, updated_at
+ FROM projects WHERE id = ?`
+ )
+ .get(id) as ProjectRow | undefined;
+ return row ? toProject(row) : null;
+}
+
+export function getProjectByName(name: string): Project | null {
+ const db = getDb();
+ const row = db
+ .prepare(
+ `SELECT id, name, color, created_at, updated_at
+ FROM projects WHERE LOWER(name) = LOWER(?)`
+ )
+ .get(name.trim()) as ProjectRow | undefined;
+ return row ? toProject(row) : null;
+}
+
+export function getProjectCounts(id: string): {
+ appCount: number;
+ keywordCount: number;
+} {
+ const db = getDb();
+ const appRow = db
+ .prepare(
+ `SELECT COUNT(*) AS n FROM owned_apps WHERE project_id = ? AND kind = 'owned'`
+ )
+ .get(id) as { n: number };
+ const keywordRow = db
+ .prepare(
+ `SELECT COUNT(*) AS n
+ FROM app_keywords ak
+ JOIN owned_apps oa ON oa.id = ak.app_id
+ WHERE oa.project_id = ?`
+ )
+ .get(id) as { n: number };
+ return {
+ appCount: Number(appRow?.n ?? 0),
+ keywordCount: Number(keywordRow?.n ?? 0),
+ };
+}
+
+export function createProject(input: {
+ name: string;
+ color?: ProjectColor;
+}): Project {
+ const validation = validateProjectName(input.name);
+ if (!validation.ok) {
+ throw new ProjectError(
+ validation.reason === "empty" ? "INVALID_NAME" : "NAME_TOO_LONG",
+ validation.reason === "empty"
+ ? "Project name cannot be empty."
+ : "Project name is too long."
+ );
+ }
+ const name = validation.value;
+ const existingByName = getProjectByName(name);
+ if (existingByName) {
+ throw new ProjectError(
+ "DUPLICATE_NAME",
+ `A project named "${name}" already exists.`
+ );
+ }
+ const color: ProjectColor = input.color ?? DEFAULT_PROJECT_COLOR;
+ const desiredSlug = slugifyProjectName(name);
+ const existing = listProjects().map((project) => project.id);
+ const id = ensureUniqueSlug(desiredSlug, existing);
+ const now = new Date().toISOString();
+ const db = getDb();
+ const tx = db.transaction(() => {
+ db.prepare(
+ `INSERT INTO projects (id, name, color, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`
+ ).run(id, name, color, now, now);
+ db.prepare(
+ `INSERT OR IGNORE INTO owned_apps (id, kind, name, icon_json, project_id)
+ VALUES (?, 'research', ?, NULL, ?)`
+ ).run(researchAppIdForProject(id), DEFAULT_RESEARCH_APP_NAME, id);
+ });
+ tx();
+ const created = getProjectById(id);
+ if (!created) {
+ throw new ProjectError(
+ "UNEXPECTED_ERROR",
+ "Project was not created successfully."
+ );
+ }
+ return created;
+}
+
+export function updateProject(
+ id: string,
+ patch: { name?: string; color?: ProjectColor }
+): Project | null {
+ const existing = getProjectById(id);
+ if (!existing) return null;
+ const nextName = patch.name !== undefined ? patch.name : existing.name;
+ const validation = validateProjectName(nextName);
+ if (!validation.ok) {
+ throw new ProjectError(
+ validation.reason === "empty" ? "INVALID_NAME" : "NAME_TOO_LONG",
+ validation.reason === "empty"
+ ? "Project name cannot be empty."
+ : "Project name is too long."
+ );
+ }
+ const resolvedName = validation.value;
+ if (resolvedName.toLowerCase() !== existing.name.toLowerCase()) {
+ const conflict = getProjectByName(resolvedName);
+ if (conflict && conflict.id !== id) {
+ throw new ProjectError(
+ "DUPLICATE_NAME",
+ `A project named "${resolvedName}" already exists.`
+ );
+ }
+ }
+ const color = patch.color ?? existing.color;
+ const now = new Date().toISOString();
+ const db = getDb();
+ db.prepare(
+ `UPDATE projects
+ SET name = ?, color = ?, updated_at = ?
+ WHERE id = ?`
+ ).run(resolvedName, color, now, id);
+ return getProjectById(id);
+}
+
+export function deleteProject(id: string): {
+ deletedProjectId: string;
+ deletedAppCount: number;
+ deletedKeywordCount: number;
+} | null {
+ const existing = getProjectById(id);
+ if (!existing) return null;
+ const projects = listProjects();
+ if (projects.length <= 1) {
+ throw new ProjectError(
+ "LAST_PROJECT",
+ "Cannot delete the only remaining project."
+ );
+ }
+ const counts = getProjectCounts(id);
+ const db = getDb();
+ const tx = db.transaction(() => {
+ const appIdRows = db
+ .prepare(`SELECT id FROM owned_apps WHERE project_id = ?`)
+ .all(id) as Array<{ id: string }>;
+ for (const row of appIdRows) {
+ db.prepare(`DELETE FROM app_keywords WHERE app_id = ?`).run(row.id);
+ db.prepare(
+ `DELETE FROM app_keyword_position_history WHERE app_id = ?`
+ ).run(row.id);
+ db.prepare(`DELETE FROM owned_apps WHERE id = ?`).run(row.id);
+ }
+ db.prepare(`DELETE FROM projects WHERE id = ?`).run(id);
+ });
+ tx();
+ return {
+ deletedProjectId: id,
+ deletedAppCount: counts.appCount,
+ deletedKeywordCount: counts.keywordCount,
+ };
+}
+
+export function isDefaultProject(id: string): boolean {
+ return id === DEFAULT_PROJECT_ID;
+}
diff --git a/cli/db/store.migrations.test.ts b/cli/db/store.migrations.test.ts
new file mode 100644
index 0000000..6a0c4f3
--- /dev/null
+++ b/cli/db/store.migrations.test.ts
@@ -0,0 +1,208 @@
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import Database from "better-sqlite3";
+import { closeDbForTests, getDb } from "./store";
+import { DEFAULT_PROJECT_ID } from "../shared/project-types";
+import { researchAppIdForProject } from "../shared/aso-research";
+
+function tempDbPath(suffix: string): string {
+ return path.join(
+ os.tmpdir(),
+ `aso-store-migrations-${suffix}-${process.pid}-${Date.now()}.sqlite`
+ );
+}
+
+function cleanDbFiles(dbPath: string): void {
+ for (const suffix of ["", "-wal", "-shm"]) {
+ try {
+ fs.unlinkSync(`${dbPath}${suffix}`);
+ } catch {}
+ }
+}
+
+function runDdl(raw: Database.Database, sql: string): void {
+ raw.exec(sql);
+}
+
+function seedLegacySchema(dbPath: string): void {
+ const raw = new Database(dbPath);
+ raw.pragma("journal_mode = WAL");
+ raw.pragma("foreign_keys = ON");
+ runDdl(
+ raw,
+ `CREATE TABLE IF NOT EXISTS owned_apps (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('owned', 'research')),
+ name TEXT NOT NULL,
+ icon_json TEXT
+ );
+ CREATE TABLE IF NOT EXISTS owned_app_country_ratings (
+ app_id TEXT NOT NULL,
+ country TEXT NOT NULL,
+ average_user_rating REAL,
+ user_rating_count INTEGER,
+ previous_average_user_rating REAL,
+ previous_user_rating_count INTEGER,
+ expires_at TEXT,
+ last_fetched_at TEXT,
+ PRIMARY KEY (app_id, country),
+ FOREIGN KEY (app_id) REFERENCES owned_apps(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS app_keywords (
+ app_id TEXT NOT NULL,
+ keyword TEXT NOT NULL,
+ country TEXT NOT NULL,
+ is_favorite INTEGER NOT NULL DEFAULT 0,
+ previous_position INTEGER,
+ added_at TEXT,
+ PRIMARY KEY (app_id, keyword, country)
+ );
+ CREATE TABLE IF NOT EXISTS app_keyword_position_history (
+ app_id TEXT NOT NULL,
+ keyword TEXT NOT NULL,
+ country TEXT NOT NULL,
+ position INTEGER,
+ captured_at TEXT NOT NULL,
+ PRIMARY KEY (app_id, keyword, country, captured_at)
+ );
+ CREATE TABLE IF NOT EXISTS metadata (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );`
+ );
+ raw.prepare(
+ `INSERT INTO owned_apps (id, kind, name) VALUES (?, 'research', 'Research')`
+ ).run("research");
+ raw.prepare(
+ `INSERT INTO owned_apps (id, kind, name) VALUES (?, 'owned', 'App One')`
+ ).run("111");
+ raw.prepare(
+ `INSERT INTO app_keywords (app_id, keyword, country, is_favorite, previous_position, added_at)
+ VALUES (?, ?, ?, 0, NULL, ?)`
+ ).run("research", "halal", "US", new Date().toISOString());
+ raw.prepare(
+ `INSERT INTO app_keywords (app_id, keyword, country, is_favorite, previous_position, added_at)
+ VALUES (?, ?, ?, 0, NULL, ?)`
+ ).run("111", "scanner", "US", new Date().toISOString());
+ raw.prepare(
+ `INSERT INTO app_keyword_position_history (app_id, keyword, country, position, captured_at)
+ VALUES (?, ?, ?, ?, ?)`
+ ).run("research", "halal", "US", 7, new Date().toISOString());
+ raw.close();
+}
+
+describe("store migration", () => {
+ const dbPath = tempDbPath("legacy");
+
+ afterEach(() => {
+ closeDbForTests();
+ cleanDbFiles(dbPath);
+ delete process.env.ASO_DB_PATH;
+ });
+
+ it("creates projects table + default project and renames legacy research app", () => {
+ seedLegacySchema(dbPath);
+ process.env.ASO_DB_PATH = dbPath;
+
+ const db = getDb();
+ const projects = db.prepare(`SELECT id, name FROM projects`).all() as Array<{
+ id: string;
+ name: string;
+ }>;
+ expect(projects).toHaveLength(1);
+ expect(projects[0].id).toBe(DEFAULT_PROJECT_ID);
+
+ const legacyRow = db
+ .prepare(`SELECT id FROM owned_apps WHERE id = 'research'`)
+ .get();
+ expect(legacyRow).toBeUndefined();
+
+ const targetResearchId = researchAppIdForProject(DEFAULT_PROJECT_ID);
+ const renamed = db
+ .prepare(`SELECT id, project_id, kind FROM owned_apps WHERE id = ?`)
+ .get(targetResearchId) as
+ | { id: string; project_id: string; kind: string }
+ | undefined;
+ expect(renamed).toBeDefined();
+ expect(renamed?.project_id).toBe(DEFAULT_PROJECT_ID);
+ expect(renamed?.kind).toBe("research");
+
+ const ownedAppRow = db
+ .prepare(`SELECT project_id FROM owned_apps WHERE id = '111'`)
+ .get() as { project_id: string } | undefined;
+ expect(ownedAppRow?.project_id).toBe(DEFAULT_PROJECT_ID);
+
+ const keywordRow = db
+ .prepare(
+ `SELECT app_id FROM app_keywords WHERE keyword = 'halal' AND country = 'US'`
+ )
+ .get() as { app_id: string } | undefined;
+ expect(keywordRow?.app_id).toBe(targetResearchId);
+
+ const historyRow = db
+ .prepare(
+ `SELECT app_id FROM app_keyword_position_history WHERE keyword = 'halal'`
+ )
+ .get() as { app_id: string } | undefined;
+ expect(historyRow?.app_id).toBe(targetResearchId);
+ });
+
+ it("is idempotent when run twice", () => {
+ seedLegacySchema(dbPath);
+ process.env.ASO_DB_PATH = dbPath;
+
+ getDb();
+ closeDbForTests();
+ expect(() => getDb()).not.toThrow();
+
+ const db = getDb();
+ const projectCount = db
+ .prepare(`SELECT COUNT(*) as n FROM projects`)
+ .get() as { n: number };
+ expect(projectCount.n).toBe(1);
+
+ const ownedCount = db
+ .prepare(`SELECT COUNT(*) as n FROM owned_apps`)
+ .get() as { n: number };
+ expect(ownedCount.n).toBe(2);
+ });
+
+ it("skips rename when ASO_PROJECTS_SKIP_RESEARCH_RENAME=1", () => {
+ seedLegacySchema(dbPath);
+ process.env.ASO_DB_PATH = dbPath;
+ process.env.ASO_PROJECTS_SKIP_RESEARCH_RENAME = "1";
+
+ const db = getDb();
+ const legacyRow = db
+ .prepare(`SELECT id FROM owned_apps WHERE id = 'research'`)
+ .get();
+ expect(legacyRow).toBeDefined();
+
+ delete process.env.ASO_PROJECTS_SKIP_RESEARCH_RENAME;
+ });
+
+ it("initializes a fresh database cleanly", () => {
+ process.env.ASO_DB_PATH = dbPath;
+ cleanDbFiles(dbPath);
+
+ const db = getDb();
+ const projectRow = db
+ .prepare(`SELECT id, name, color FROM projects WHERE id = ?`)
+ .get(DEFAULT_PROJECT_ID) as
+ | { id: string; name: string; color: string }
+ | undefined;
+ expect(projectRow?.id).toBe(DEFAULT_PROJECT_ID);
+ expect(projectRow?.name).toBe("Default");
+ expect(projectRow?.color).toBe("slate");
+
+ const columns = db
+ .prepare(`PRAGMA table_info(owned_apps)`)
+ .all() as Array<{ name: string }>;
+ const projectIdColumn = columns.find(
+ (column) => column.name === "project_id"
+ );
+ expect(projectIdColumn).toBeDefined();
+ });
+});
diff --git a/cli/db/store.ts b/cli/db/store.ts
index d66c9be..6f90037 100644
--- a/cli/db/store.ts
+++ b/cli/db/store.ts
@@ -2,8 +2,20 @@ import * as fs from "fs";
import * as path from "path";
import Database from "better-sqlite3";
import { ASO_ENV } from "../shared/aso-env";
+import {
+ DEFAULT_PROJECT_COLOR,
+ DEFAULT_PROJECT_ID,
+ DEFAULT_PROJECT_NAME,
+} from "../shared/project-types";
+import {
+ RESEARCH_APP_ID,
+ researchAppIdForProject,
+} from "../shared/aso-research";
+
let db: Database.Database | null = null;
+const SKIP_RESEARCH_RENAME_ENV = "ASO_PROJECTS_SKIP_RESEARCH_RENAME";
+
function ensureAppKeywordFavoriteColumn(database: Database.Database): void {
const columns = database
.prepare(`PRAGMA table_info(app_keywords)`)
@@ -17,6 +29,111 @@ function ensureAppKeywordFavoriteColumn(database: Database.Database): void {
}
}
+function ensureProjectsTable(database: Database.Database): void {
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ color TEXT NOT NULL DEFAULT '${DEFAULT_PROJECT_COLOR}',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ `);
+}
+
+function ensureDefaultProjectRow(database: Database.Database): void {
+ const now = new Date().toISOString();
+ database
+ .prepare(
+ `INSERT OR IGNORE INTO projects (id, name, color, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`
+ )
+ .run(
+ DEFAULT_PROJECT_ID,
+ DEFAULT_PROJECT_NAME,
+ DEFAULT_PROJECT_COLOR,
+ now,
+ now
+ );
+}
+
+function ensureProjectIdColumnOnOwnedApps(database: Database.Database): void {
+ const columns = database
+ .prepare(`PRAGMA table_info(owned_apps)`)
+ .all() as Array<{ name?: string }>;
+ const hasProjectIdColumn = columns.some(
+ (column) => column.name === "project_id"
+ );
+ if (!hasProjectIdColumn) {
+ // SQLite does not allow ADD COLUMN with REFERENCES+NOT NULL default.
+ // Referential integrity is enforced at the application layer in projects.ts
+ // (deleteProject cascades owned_apps rows manually).
+ database.exec(
+ `ALTER TABLE owned_apps
+ ADD COLUMN project_id TEXT NOT NULL DEFAULT '${DEFAULT_PROJECT_ID}'`
+ );
+ }
+}
+
+function ensureOwnedAppsProjectIndexes(database: Database.Database): void {
+ database.exec(`
+ CREATE INDEX IF NOT EXISTS idx_owned_apps_project
+ ON owned_apps(project_id, kind);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_owned_apps_research_per_project
+ ON owned_apps(project_id) WHERE kind = 'research';
+ `);
+}
+
+function migrateLegacyResearchAppId(database: Database.Database): void {
+ if (process.env[SKIP_RESEARCH_RENAME_ENV] === "1") return;
+ const legacy = database
+ .prepare(`SELECT id FROM owned_apps WHERE id = ?`)
+ .get(RESEARCH_APP_ID) as { id: string } | undefined;
+ if (!legacy) return;
+
+ const targetId = researchAppIdForProject(DEFAULT_PROJECT_ID);
+ const conflict = database
+ .prepare(`SELECT id FROM owned_apps WHERE id = ?`)
+ .get(targetId) as { id: string } | undefined;
+ if (conflict) return;
+
+ const fkPragma = database.pragma("foreign_keys", { simple: true });
+ const wasFkOn =
+ fkPragma === 1 || fkPragma === "1" || fkPragma === true;
+ if (wasFkOn) {
+ database.pragma("foreign_keys = OFF");
+ }
+ try {
+ const tx = database.transaction(() => {
+ database
+ .prepare(`UPDATE app_keywords SET app_id = ? WHERE app_id = ?`)
+ .run(targetId, RESEARCH_APP_ID);
+ database
+ .prepare(
+ `UPDATE owned_app_country_ratings SET app_id = ? WHERE app_id = ?`
+ )
+ .run(targetId, RESEARCH_APP_ID);
+ database
+ .prepare(
+ `UPDATE app_keyword_position_history SET app_id = ? WHERE app_id = ?`
+ )
+ .run(targetId, RESEARCH_APP_ID);
+ database
+ .prepare(
+ `UPDATE owned_apps
+ SET id = ?, project_id = ?
+ WHERE id = ?`
+ )
+ .run(targetId, DEFAULT_PROJECT_ID, RESEARCH_APP_ID);
+ });
+ tx();
+ } finally {
+ if (wasFkOn) {
+ database.pragma("foreign_keys = ON");
+ }
+ }
+}
+
function initializeDatabase(database: Database.Database): void {
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
@@ -139,6 +256,11 @@ function initializeDatabase(database: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_aso_keyword_failures_country_stage
ON aso_keyword_failures(country, stage);
`);
+ ensureProjectsTable(database);
+ ensureDefaultProjectRow(database);
+ ensureProjectIdColumnOnOwnedApps(database);
+ ensureOwnedAppsProjectIndexes(database);
+ migrateLegacyResearchAppId(database);
ensureAppKeywordFavoriteColumn(database);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_app_keywords_country_app_favorite
diff --git a/cli/db/types.ts b/cli/db/types.ts
index 43b98ce..06364d3 100644
--- a/cli/db/types.ts
+++ b/cli/db/types.ts
@@ -11,6 +11,7 @@ export interface StoredOwnedApp {
id: string;
kind: OwnedAppKind;
name: string;
+ projectId: string;
averageUserRating: number | null;
userRatingCount: number | null;
previousAverageUserRating: number | null;
diff --git a/cli/shared/aso-research.test.ts b/cli/shared/aso-research.test.ts
new file mode 100644
index 0000000..5ff87e1
--- /dev/null
+++ b/cli/shared/aso-research.test.ts
@@ -0,0 +1,44 @@
+import {
+ DEFAULT_RESEARCH_APP_ID,
+ RESEARCH_APP_ID,
+ RESEARCH_APP_ID_PREFIX,
+ isResearchAppId,
+ projectIdFromResearchAppId,
+ researchAppIdForProject,
+} from "./aso-research";
+
+describe("aso-research helpers", () => {
+ it("recognizes bare and suffixed research ids", () => {
+ expect(isResearchAppId(RESEARCH_APP_ID)).toBe(true);
+ expect(isResearchAppId(DEFAULT_RESEARCH_APP_ID)).toBe(true);
+ expect(isResearchAppId(`${RESEARCH_APP_ID_PREFIX}halal-food`)).toBe(true);
+ expect(isResearchAppId("6755012360")).toBe(false);
+ expect(isResearchAppId("")).toBe(false);
+ expect(isResearchAppId(" ")).toBe(false);
+ });
+
+ it("builds research app id for a project", () => {
+ expect(researchAppIdForProject("default")).toBe("research:default");
+ expect(researchAppIdForProject("halal-food")).toBe("research:halal-food");
+ expect(researchAppIdForProject(" spaced ")).toBe("research:spaced");
+ });
+
+ it("throws when building research app id without a project", () => {
+ expect(() => researchAppIdForProject("")).toThrow();
+ expect(() => researchAppIdForProject(" ")).toThrow();
+ });
+
+ it("extracts projectId from suffixed research id", () => {
+ expect(projectIdFromResearchAppId("research:default")).toBe("default");
+ expect(projectIdFromResearchAppId("research:halal-food")).toBe(
+ "halal-food"
+ );
+ });
+
+ it("returns null for non-prefixed or bare research ids", () => {
+ expect(projectIdFromResearchAppId("research")).toBeNull();
+ expect(projectIdFromResearchAppId("6755012360")).toBeNull();
+ expect(projectIdFromResearchAppId("")).toBeNull();
+ expect(projectIdFromResearchAppId("research:")).toBeNull();
+ });
+});
diff --git a/cli/shared/aso-research.ts b/cli/shared/aso-research.ts
index 056dc8d..07b5187 100644
--- a/cli/shared/aso-research.ts
+++ b/cli/shared/aso-research.ts
@@ -11,3 +11,20 @@ export function isResearchAppId(appId: string): boolean {
normalized.startsWith(RESEARCH_APP_ID_PREFIX)
);
}
+
+export function researchAppIdForProject(projectId: string): string {
+ const trimmed = projectId.trim();
+ if (!trimmed) {
+ throw new Error("projectId is required to build a research app id");
+ }
+ return `${RESEARCH_APP_ID_PREFIX}${trimmed}`;
+}
+
+export function projectIdFromResearchAppId(appId: string): string | null {
+ const trimmed = appId.trim();
+ if (!trimmed) return null;
+ if (trimmed === RESEARCH_APP_ID) return null;
+ if (!trimmed.startsWith(RESEARCH_APP_ID_PREFIX)) return null;
+ const projectId = trimmed.slice(RESEARCH_APP_ID_PREFIX.length);
+ return projectId.length > 0 ? projectId : null;
+}
diff --git a/cli/shared/project-types.test.ts b/cli/shared/project-types.test.ts
new file mode 100644
index 0000000..c7e689a
--- /dev/null
+++ b/cli/shared/project-types.test.ts
@@ -0,0 +1,115 @@
+import {
+ DEFAULT_PROJECT_COLOR,
+ DEFAULT_PROJECT_ID,
+ DEFAULT_PROJECT_NAME,
+ PROJECT_COLORS,
+ PROJECT_NAME_MAX_LENGTH,
+ ensureUniqueSlug,
+ isValidProjectColor,
+ slugifyProjectName,
+ validateProjectName,
+} from "./project-types";
+
+describe("project-types helpers", () => {
+ it("exposes the expected default project sentinel", () => {
+ expect(DEFAULT_PROJECT_ID).toBe("default");
+ expect(DEFAULT_PROJECT_NAME).toBe("Default");
+ expect((PROJECT_COLORS as readonly string[]).includes(DEFAULT_PROJECT_COLOR)).toBe(true);
+ });
+
+ describe("isValidProjectColor", () => {
+ it("accepts preset colors", () => {
+ for (const color of PROJECT_COLORS) {
+ expect(isValidProjectColor(color)).toBe(true);
+ }
+ });
+ it("rejects other values", () => {
+ expect(isValidProjectColor("maroon")).toBe(false);
+ expect(isValidProjectColor("")).toBe(false);
+ expect(isValidProjectColor(123)).toBe(false);
+ expect(isValidProjectColor(null)).toBe(false);
+ });
+ });
+
+ describe("validateProjectName", () => {
+ it("accepts and trims valid names", () => {
+ expect(validateProjectName("Halal Food")).toEqual({
+ ok: true,
+ value: "Halal Food",
+ });
+ expect(validateProjectName(" spaced ")).toEqual({
+ ok: true,
+ value: "spaced",
+ });
+ });
+
+ it("rejects empty / whitespace-only", () => {
+ expect(validateProjectName("")).toEqual({ ok: false, reason: "empty" });
+ expect(validateProjectName(" ")).toEqual({ ok: false, reason: "empty" });
+ expect(validateProjectName(123)).toEqual({ ok: false, reason: "empty" });
+ });
+
+ it("rejects names above max length", () => {
+ const tooLong = "x".repeat(PROJECT_NAME_MAX_LENGTH + 1);
+ expect(validateProjectName(tooLong)).toEqual({
+ ok: false,
+ reason: "too_long",
+ });
+ });
+ });
+
+ describe("slugifyProjectName", () => {
+ it("lowercases and replaces non-alphanumeric with dashes", () => {
+ expect(slugifyProjectName("Halal Food")).toBe("halal-food");
+ expect(slugifyProjectName("My App 2024!")).toBe("my-app-2024");
+ });
+
+ it("normalizes Turkish diacritics", () => {
+ expect(slugifyProjectName("Altın Takibi")).toBe("altin-takibi");
+ expect(slugifyProjectName("Şeker Ürünü")).toBe("seker-urunu");
+ expect(slugifyProjectName("Çiğdem Öznur Güneş")).toBe(
+ "cigdem-oznur-gunes"
+ );
+ });
+
+ it("collapses repeated dashes and trims edges", () => {
+ expect(slugifyProjectName("---weird----name---")).toBe("weird-name");
+ expect(slugifyProjectName("___underscores___")).toBe("underscores");
+ });
+
+ it("falls back to 'project' when name yields empty slug", () => {
+ expect(slugifyProjectName("!!!")).toBe("project");
+ expect(slugifyProjectName("🎉🎉")).toBe("project");
+ expect(slugifyProjectName(" ")).toBe("project");
+ });
+
+ it("truncates to max slug length", () => {
+ const long = "a".repeat(120);
+ const slug = slugifyProjectName(long);
+ expect(slug.length).toBeLessThanOrEqual(48);
+ expect(slug.startsWith("a")).toBe(true);
+ expect(slug.endsWith("-")).toBe(false);
+ });
+ });
+
+ describe("ensureUniqueSlug", () => {
+ it("returns original slug when unique", () => {
+ expect(ensureUniqueSlug("halal-food", [])).toBe("halal-food");
+ expect(ensureUniqueSlug("halal-food", ["default"])).toBe("halal-food");
+ });
+
+ it("appends -2, -3, ... on collisions", () => {
+ expect(ensureUniqueSlug("halal-food", ["halal-food"])).toBe(
+ "halal-food-2"
+ );
+ expect(
+ ensureUniqueSlug("halal-food", ["halal-food", "halal-food-2"])
+ ).toBe("halal-food-3");
+ });
+
+ it("accepts Set inputs", () => {
+ const existing = new Set(["halal-food", "halal-food-2"]);
+ expect(ensureUniqueSlug("halal-food", existing)).toBe("halal-food-3");
+ });
+ });
+});
diff --git a/cli/shared/project-types.ts b/cli/shared/project-types.ts
new file mode 100644
index 0000000..1c236bd
--- /dev/null
+++ b/cli/shared/project-types.ts
@@ -0,0 +1,113 @@
+export const DEFAULT_PROJECT_ID = "default";
+export const DEFAULT_PROJECT_NAME = "Default";
+
+export const PROJECT_COLORS = [
+ "slate",
+ "blue",
+ "green",
+ "amber",
+ "red",
+ "purple",
+ "pink",
+ "teal",
+] as const;
+
+export type ProjectColor = (typeof PROJECT_COLORS)[number];
+
+export const DEFAULT_PROJECT_COLOR: ProjectColor = "slate";
+
+export type Project = {
+ id: string;
+ name: string;
+ color: ProjectColor;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type ProjectSummary = Project & {
+ appCount: number;
+ keywordCount: number;
+};
+
+export type ProjectCreateRequest = {
+ name: string;
+ color?: ProjectColor;
+};
+
+export type ProjectUpdateRequest = {
+ name?: string;
+ color?: ProjectColor;
+};
+
+export const PROJECT_NAME_MIN_LENGTH = 1;
+export const PROJECT_NAME_MAX_LENGTH = 60;
+export const PROJECT_SLUG_MAX_LENGTH = 48;
+
+export function isValidProjectColor(value: unknown): value is ProjectColor {
+ return (
+ typeof value === "string" &&
+ (PROJECT_COLORS as readonly string[]).includes(value)
+ );
+}
+
+export type ProjectNameValidation =
+ | { ok: true; value: string }
+ | { ok: false; reason: "empty" | "too_long" };
+
+export function validateProjectName(name: unknown): ProjectNameValidation {
+ if (typeof name !== "string") return { ok: false, reason: "empty" };
+ const trimmed = name.trim();
+ if (trimmed.length < PROJECT_NAME_MIN_LENGTH) {
+ return { ok: false, reason: "empty" };
+ }
+ if (trimmed.length > PROJECT_NAME_MAX_LENGTH) {
+ return { ok: false, reason: "too_long" };
+ }
+ return { ok: true, value: trimmed };
+}
+
+const TURKISH_DIACRITIC_MAP: Record = {
+ ı: "i",
+ İ: "i",
+ ş: "s",
+ Ş: "s",
+ ğ: "g",
+ Ğ: "g",
+ ü: "u",
+ Ü: "u",
+ ö: "o",
+ Ö: "o",
+ ç: "c",
+ Ç: "c",
+};
+
+function normalizeDiacritics(input: string): string {
+ let out = "";
+ for (const ch of input) {
+ out += TURKISH_DIACRITIC_MAP[ch] ?? ch;
+ }
+ return out.normalize("NFKD").replace(/[̀-ͯ]/g, "");
+}
+
+export function slugifyProjectName(name: string): string {
+ const lowered = normalizeDiacritics(name).toLowerCase();
+ const replaced = lowered.replace(/[^a-z0-9]+/g, "-");
+ const trimmed = replaced.replace(/^-+|-+$/g, "");
+ const truncated = trimmed.slice(0, PROJECT_SLUG_MAX_LENGTH).replace(/-+$/g, "");
+ if (truncated.length === 0) return "project";
+ return truncated;
+}
+
+export function ensureUniqueSlug(
+ slug: string,
+ existingIds: Iterable
+): string {
+ const existing = new Set();
+ for (const id of existingIds) existing.add(id);
+ if (!existing.has(slug)) return slug;
+ let counter = 2;
+ while (existing.has(`${slug}-${counter}`)) {
+ counter += 1;
+ }
+ return `${slug}-${counter}`;
+}
From 90a446b415eda6af15326b60b3431e3096f7b943 Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Sat, 25 Apr 2026 19:57:56 +0300
Subject: [PATCH 04/55] Reduce Bugsnag trace payloads and fix Apple auth drift
classification
---
cli/services/auth/aso-auth-service.test.ts | 81 ++++++++++++
cli/services/auth/aso-auth-service.ts | 54 +++++++-
.../keywords/apple-http-trace.test.ts | 46 ++++++-
cli/services/keywords/apple-http-trace.ts | 123 +++++++++++++-----
.../telemetry/bugsnag-classifier.test.ts | 14 ++
cli/shared/telemetry/bugsnag-classifier.ts | 1 +
docs/aso-error-handling.md | 5 +-
7 files changed, 284 insertions(+), 40 deletions(-)
diff --git a/cli/services/auth/aso-auth-service.test.ts b/cli/services/auth/aso-auth-service.test.ts
index 799450c..193de34 100644
--- a/cli/services/auth/aso-auth-service.test.ts
+++ b/cli/services/auth/aso-auth-service.test.ts
@@ -11,6 +11,7 @@ import inquirer from "inquirer";
import path from "path";
import { asoCookieStoreService } from "./aso-cookie-store-service";
import { asoKeychainService } from "./aso-keychain-service";
+import * as appleHttpTrace from "../keywords/apple-http-trace";
function loadFixture(name: string): unknown {
return JSON.parse(
@@ -215,6 +216,32 @@ describe("aso-auth-service legacy parity edge handling", () => {
});
});
+ it("maps 401 -20101 responses to invalid credentials without contract drift", async () => {
+ const engine = createEngine();
+ const reportContractSpy = jest
+ .spyOn(appleHttpTrace, "reportAppleContractChange")
+ .mockImplementation(() => {});
+
+ await expect(
+ (engine as any).handlePostLoginResponse({
+ status: 401,
+ data: {
+ serviceErrors: [
+ {
+ code: "-20101",
+ message: "Check the account information you entered and try again.",
+ },
+ ],
+ },
+ headers: {},
+ })
+ ).rejects.toMatchObject({
+ name: "AppleAuthResponseError",
+ reason: "invalid_credentials",
+ });
+ expect(reportContractSpy).not.toHaveBeenCalled();
+ });
+
it("maps itctx cookie responses to explicit account-access error", async () => {
const engine = createEngine();
@@ -773,6 +800,60 @@ describe("aso-auth-service 2FA method handling", () => {
).rejects.toThrow("No supported 2FA verification methods were returned.");
});
+ it("treats Apple -28248 as verification delivery failure instead of contract drift", async () => {
+ const request = jest
+ .fn()
+ .mockResolvedValueOnce({
+ status: 500,
+ data: {
+ noTrustedDevices: true,
+ trustedPhoneNumbers: [],
+ trustedDevices: [],
+ serviceErrors: [
+ {
+ code: "-28248",
+ title: "Verification Failed",
+ message:
+ "Verification codes cannot be sent to this phone number at this time. Please try again later.",
+ },
+ ],
+ },
+ headers: {},
+ })
+ .mockResolvedValueOnce({
+ status: 500,
+ data: {
+ noTrustedDevices: true,
+ trustedPhoneNumbers: [],
+ trustedDevices: [],
+ serviceErrors: [
+ {
+ code: "-28248",
+ title: "Verification Failed",
+ message:
+ "Verification codes cannot be sent to this phone number at this time. Please try again later.",
+ },
+ ],
+ },
+ headers: {},
+ });
+ const reportContractSpy = jest
+ .spyOn(appleHttpTrace, "reportAppleContractChange")
+ .mockImplementation(() => {});
+ const engine = new AsoAuthEngine({ request } as any, "auto") as any;
+
+ await expect(
+ engine.handleTwoFactor({
+ scnt: "scnt",
+ xAppleIdSessionId: "session-id",
+ })
+ ).rejects.toMatchObject({
+ name: "AppleAuthResponseError",
+ reason: "verification_delivery_failed",
+ });
+ expect(reportContractSpy).not.toHaveBeenCalled();
+ });
+
it("fails fast when 2FA is attempted without interactive TTY", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
diff --git a/cli/services/auth/aso-auth-service.ts b/cli/services/auth/aso-auth-service.ts
index 2b53bfa..111e96b 100644
--- a/cli/services/auth/aso-auth-service.ts
+++ b/cli/services/auth/aso-auth-service.ts
@@ -40,6 +40,7 @@ type AppleAuthFailureReason =
| "invalid_credentials"
| "two_factor_required"
| "upgrade_required"
+ | "verification_delivery_failed"
| "unknown";
export class AppleAuthResponseError extends Error {
@@ -123,6 +124,8 @@ interface AppleAuthErrorPayload {
}
const SUPPORTED_AUTH_TYPES = new Set(["sa", "hsa", "non-sa", "hsa2"]);
+const APPLE_INVALID_CREDENTIAL_CODES = new Set(["-20101"]);
+const APPLE_TWO_FACTOR_DELIVERY_FAILURE_CODES = new Set(["-28248"]);
interface RubySirpResult {
ok: boolean;
@@ -493,8 +496,10 @@ puts({
function collectAppleServiceErrors(
payload: unknown
): Array<{ code?: string; title?: string; message?: string }> {
- if (!payload || typeof payload !== "object") return [];
- const typed = payload as AppleAuthErrorPayload;
+ const parsed =
+ typeof payload === "string" ? tryParseJsonString(payload) : payload;
+ if (!parsed || typeof parsed !== "object") return [];
+ const typed = parsed as AppleAuthErrorPayload;
const buckets = [
typed.serviceErrors,
typed.service_errors,
@@ -503,6 +508,16 @@ function collectAppleServiceErrors(
return buckets.flatMap((bucket) => (Array.isArray(bucket) ? bucket : []));
}
+function payloadHasAppleServiceErrorCode(
+ payload: unknown,
+ codes: Set
+): boolean {
+ return collectAppleServiceErrors(payload).some((error) => {
+ if (typeof error.code !== "string") return false;
+ return codes.has(error.code.trim());
+ });
+}
+
export function summarizeAppleErrorPayload(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return String(payload);
@@ -595,6 +610,9 @@ function quoteDesCookieValue(cookieHeader: string): string {
}
function payloadIndicatesInvalidCredentials(payload: unknown): boolean {
+ if (payloadHasAppleServiceErrorCode(payload, APPLE_INVALID_CREDENTIAL_CODES)) {
+ return true;
+ }
const errors = collectAppleServiceErrors(payload);
if (errors.length === 0) return false;
const combinedText = errors
@@ -608,7 +626,8 @@ function payloadIndicatesInvalidCredentials(payload: unknown): boolean {
combinedText.includes("apple id") ||
combinedText.includes("account"))) ||
combinedText.includes("incorrect password") ||
- combinedText.includes("account name or password")
+ combinedText.includes("account name or password") ||
+ combinedText.includes("check the account information you entered")
);
}
@@ -619,6 +638,12 @@ function inferAppleAuthFailureReason(
if (status === 403) return "invalid_credentials";
if (status === 409) return "two_factor_required";
if (status === 412) return "upgrade_required";
+ if (
+ status === 401 &&
+ payloadHasAppleServiceErrorCode(payload, APPLE_INVALID_CREDENTIAL_CODES)
+ ) {
+ return "invalid_credentials";
+ }
if (payloadIndicatesInvalidCredentials(payload)) return "invalid_credentials";
return "unknown";
}
@@ -1545,6 +1570,29 @@ export class AsoAuthEngine {
});
}
if (methodChoices.length === 0) {
+ if (
+ payloadHasAppleServiceErrorCode(
+ body,
+ APPLE_TWO_FACTOR_DELIVERY_FAILURE_CODES
+ )
+ ) {
+ throw this.withAppleAuthTrace(
+ new AppleAuthResponseError({
+ message: getTwoFactorVerificationErrorMessage(body),
+ status: challengeResponse.status,
+ payload: body,
+ reason: "verification_delivery_failed",
+ }),
+ "2fa-method-selection",
+ {
+ status: challengeResponse.status,
+ noTrustedDevices,
+ trustedPhoneCount: phoneNumbers.length,
+ hasTrustedDevices,
+ failureCode: "-28248",
+ }
+ );
+ }
reportAppleContractChange({
provider: "apple-auth",
operation: "2fa-method-selection",
diff --git a/cli/services/keywords/apple-http-trace.test.ts b/cli/services/keywords/apple-http-trace.test.ts
index b88a5c4..f649f3e 100644
--- a/cli/services/keywords/apple-http-trace.test.ts
+++ b/cli/services/keywords/apple-http-trace.test.ts
@@ -108,7 +108,7 @@ describe("apple-http-trace", () => {
expect(trace.request.url).toContain("plain=ok");
});
- it("attaches last 10 calls plus recent failed calls that fell out of the window", async () => {
+ it("attaches last 3 calls plus recent failed calls that fell out of the window", async () => {
const client = axios.create();
attachAppleHttpTracing(client, "apple-search-ads");
const mock = new AxiosMockAdapter(client);
@@ -155,10 +155,52 @@ describe("apple-http-trace", () => {
)
);
- expect(traceSeq).toEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2]);
+ expect(traceSeq).toEqual([10, 11, 12, 1, 2, 5]);
expect(failedSeq).toEqual([1, 2, 5]);
});
+ it("truncates oversized trace payloads to keep metadata bounded", async () => {
+ const client = axios.create();
+ attachAppleHttpTracing(client, "apple-appstore");
+ const mock = new AxiosMockAdapter(client);
+ mock.onGet("/huge").reply(500, {
+ hugeText: "x".repeat(10000),
+ list: Array.from({ length: 60 }, (_, index) => ({ index })),
+ nested: {
+ level1: {
+ level2: {
+ level3: {
+ level4: {
+ level5: {
+ level6: {
+ level7: "too-deep",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ await expect(client.get("/huge")).rejects.toThrow();
+
+ const wrapped = withAppleHttpTraceContext(new Error("bounded"), {
+ provider: "apple-appstore",
+ operation: "appstore.app-lookup",
+ });
+ const metadata = getErrorBugsnagMetadata(wrapped);
+ const traces = (metadata?.appleApi as any)?.recentHttpTraces || [];
+ const trace = traces[traces.length - 1];
+
+ expect(String(trace.response.body.hugeText)).toContain("[TRUNCATED:");
+ expect(trace.response.body.list).toHaveLength(21);
+ expect(trace.response.body.list[20]).toContain("[TRUNCATED:");
+ expect(JSON.stringify(trace.response.body.nested)).toContain(
+ "[TRUNCATED:DEPTH]"
+ );
+ });
+
it("does not redact unrelated keys that merely contain letter a or c", async () => {
const client = axios.create();
attachAppleHttpTracing(client, "apple-auth");
diff --git a/cli/services/keywords/apple-http-trace.ts b/cli/services/keywords/apple-http-trace.ts
index 49fb80f..38ca0f7 100644
--- a/cli/services/keywords/apple-http-trace.ts
+++ b/cli/services/keywords/apple-http-trace.ts
@@ -40,9 +40,13 @@ type AppleHttpTraceStoreEntry = AppleHttpTrace & {
nonSuccess: boolean;
};
-const RECENT_TRACE_LIMIT = 10;
+const RECENT_TRACE_LIMIT = 3;
const RECENT_FAILED_TRACE_LIMIT = 3;
const APPLE_CONTRACT_CHANGE_DEDUPE_WINDOW_MS = 15 * 60 * 1000;
+const TRACE_STRING_MAX_LENGTH = 2000;
+const TRACE_ARRAY_MAX_ITEMS = 20;
+const TRACE_OBJECT_MAX_KEYS = 40;
+const TRACE_MAX_DEPTH = 6;
const recentTraceStore: AppleHttpTraceStoreEntry[] = [];
const recentFailedTraceStore: AppleHttpTraceStoreEntry[] = [];
const appleContractChangeLastSeenAt = new Map();
@@ -79,26 +83,67 @@ function toRequestSnapshot(config?: AxiosRequestConfig): AppleHttpTrace["request
isSensitiveKey,
baseUrl: "https://apple.local",
}),
- params: sanitizeTelemetryValue(config?.params, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
- headers: sanitizeTelemetryValue(config?.headers, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
- body: sanitizeTelemetryValue(config?.data, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
+ params: truncateTraceValue(
+ sanitizeTelemetryValue(config?.params, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
+ headers: truncateTraceValue(
+ sanitizeTelemetryValue(config?.headers, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
+ body: truncateTraceValue(
+ sanitizeTelemetryValue(config?.data, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
};
}
+function truncateTraceValue(value: unknown, depth = 0): unknown {
+ if (value == null) return value;
+ if (depth >= TRACE_MAX_DEPTH) return "[TRUNCATED:DEPTH]";
+
+ if (typeof value === "string") {
+ if (value.length <= TRACE_STRING_MAX_LENGTH) return value;
+ const truncatedChars = value.length - TRACE_STRING_MAX_LENGTH;
+ return `${value.slice(0, TRACE_STRING_MAX_LENGTH)}...[TRUNCATED:${truncatedChars} chars]`;
+ }
+
+ if (Array.isArray(value)) {
+ const limited = value
+ .slice(0, TRACE_ARRAY_MAX_ITEMS)
+ .map((entry) => truncateTraceValue(entry, depth + 1));
+ if (value.length > TRACE_ARRAY_MAX_ITEMS) {
+ limited.push(`[TRUNCATED:${value.length - TRACE_ARRAY_MAX_ITEMS} items]`);
+ }
+ return limited;
+ }
+
+ if (typeof value === "object") {
+ const entries = Object.entries(value as Record);
+ const limitedEntries = entries
+ .slice(0, TRACE_OBJECT_MAX_KEYS)
+ .map(([key, entry]) => [key, truncateTraceValue(entry, depth + 1)]);
+ const output = Object.fromEntries(limitedEntries) as Record;
+ if (entries.length > TRACE_OBJECT_MAX_KEYS) {
+ output._truncatedKeys = entries.length - TRACE_OBJECT_MAX_KEYS;
+ }
+ return output;
+ }
+
+ return value;
+}
+
function toMetadataTrace(
entry: AppleHttpTraceStoreEntry
): AppleHttpTrace {
const { traceId: _traceId, nonSuccess: _nonSuccess, ...trace } = entry;
- return trace;
+ return truncateTraceValue(trace) as AppleHttpTrace;
}
function isNonSuccessTrace(trace: AppleHttpTrace): boolean {
@@ -206,14 +251,18 @@ export function attachAppleHttpTracing(
request: toRequestSnapshot(response.config),
response: {
status: response.status,
- headers: sanitizeTelemetryValue(response.headers, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
- body: sanitizeTelemetryValue(response.data, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
+ headers: truncateTraceValue(
+ sanitizeTelemetryValue(response.headers, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
+ body: truncateTraceValue(
+ sanitizeTelemetryValue(response.data, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
durationMs: startedAt > 0 ? Date.now() - startedAt : undefined,
},
});
@@ -228,14 +277,18 @@ export function attachAppleHttpTracing(
request: toRequestSnapshot(error.config),
response: {
status: error.response?.status,
- headers: sanitizeTelemetryValue(error.response?.headers, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
- body: sanitizeTelemetryValue(error.response?.data, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
+ headers: truncateTraceValue(
+ sanitizeTelemetryValue(error.response?.headers, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
+ body: truncateTraceValue(
+ sanitizeTelemetryValue(error.response?.data, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
durationMs: startedAt > 0 ? Date.now() - startedAt : undefined,
},
error: {
@@ -287,10 +340,12 @@ export function withAppleHttpTraceContext(
appleApi: {
provider: params.provider,
operation: params.operation,
- context: sanitizeTelemetryValue(params.context || {}, {
- isSensitiveKey,
- parseJsonStrings: true,
- }),
+ context: truncateTraceValue(
+ sanitizeTelemetryValue(params.context || {}, {
+ isSensitiveKey,
+ parseJsonStrings: true,
+ })
+ ),
recentHttpTraces: [...recentHttpTraces, ...extraRecentFailedHttpTraces],
recentFailedHttpTraces,
},
diff --git a/cli/shared/telemetry/bugsnag-classifier.test.ts b/cli/shared/telemetry/bugsnag-classifier.test.ts
index 5d7e533..dd82062 100644
--- a/cli/shared/telemetry/bugsnag-classifier.test.ts
+++ b/cli/shared/telemetry/bugsnag-classifier.test.ts
@@ -109,6 +109,20 @@ describe("bugsnag-classifier", () => {
expect(decision.classification).toBe("user_fault");
});
+ it("classifies Apple 2FA verification delivery failures as user_fault", () => {
+ const error = Object.assign(new Error("Verification codes cannot be sent"), {
+ name: "AppleAuthResponseError",
+ reason: "verification_delivery_failed",
+ });
+
+ const decision = classifyTelemetryError(error, {});
+ expect(decision).toEqual({
+ report: true,
+ classification: "user_fault",
+ reason: "apple_auth_verification_delivery_failed",
+ });
+ });
+
it("classifies mcp parse-json payload drift as user-fault noise", () => {
const decision = classifyTelemetryError(
new Error("MCP expected JSON output from aso keywords"),
diff --git a/cli/shared/telemetry/bugsnag-classifier.ts b/cli/shared/telemetry/bugsnag-classifier.ts
index 24d12e4..880af41 100644
--- a/cli/shared/telemetry/bugsnag-classifier.ts
+++ b/cli/shared/telemetry/bugsnag-classifier.ts
@@ -68,6 +68,7 @@ const APPLE_AUTH_USER_FAULT_REASONS = new Set([
"invalid_credentials",
"two_factor_required",
"upgrade_required",
+ "verification_delivery_failed",
]);
function getTelemetryHint(metadata: AnyRecord): TelemetryHint | undefined {
diff --git a/docs/aso-error-handling.md b/docs/aso-error-handling.md
index 0b0c11a..52c666e 100644
--- a/docs/aso-error-handling.md
+++ b/docs/aso-error-handling.md
@@ -59,7 +59,7 @@ Define failure boundaries, retry rules, and recovery behavior across CLI, dashbo
## Observability
- Apple HTTP calls carry trace context.
-- Bugsnag Apple metadata includes the latest `10` redacted Apple HTTP calls plus up to `3` latest non-success calls when they have already rotated out of that `10`-call window.
+- Bugsnag Apple metadata includes the latest `3` redacted Apple HTTP calls plus up to `3` latest non-success calls when they have already rotated out of that `3`-call window.
- Apple contract-drift reporting is centralized: when expected Apple response shapes/flow contracts drift, the runtime emits Bugsnag events classified as `apple_contract_change` with endpoint + expected-vs-actual metadata.
- Contract-drift reporting covers all Apple API surfaces used by ASO runtime:
- Apple auth/session bootstrap and 2FA flow
@@ -80,6 +80,9 @@ Define failure boundaries, retry rules, and recovery behavior across CLI, dashbo
- reports internal bugs, Apple contract-change signals, and terminal upstream failures
- suppresses expected flow/validation noise (`4xx`, validation issues)
- reports selected user-fault noise as low-severity (`info`, handled) for visibility without paging
+- Apple auth `401` responses carrying Apple service code `-20101` are classified as `invalid_credentials` (`user_fault`) instead of contract drift.
+- Apple 2FA challenge payloads with service code `-28248` (verification code delivery unavailable) are classified as verification-delivery `user_fault` instead of contract drift.
+- Apple HTTP trace metadata attached to Bugsnag is size-bounded (string/array/object/depth truncation) so contract-drift events retain actionable metadata instead of being dropped for oversized payloads.
- Dashboard UI reports only actionable API failures (for example: `5xx`, network/runtime exceptions, malformed success payloads); expected `4xx` flows are suppressed.
- Dashboard UI transport/setup noise (`/api/aso/auth/status` network fetch failures and repeated local search failures) is reclassified as `user_fault` and deduped in-process per signature for `60` seconds.
- MCP parse-json shape drift (`MCP expected JSON output from aso keywords`) is reclassified as `user_fault` and deduped in the shared reporter path for `60` seconds.
From 9c3c8087b7501ba548fb71ae98f0221cb699554f Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Sun, 26 Apr 2026 11:49:45 +0300
Subject: [PATCH 05/55] add usage analytics
---
.env.example | 4 +
cli/cli.ts | 14 +-
.../runtime/aso-config-service.test.ts | 81 ++++
cli/services/runtime/aso-config-service.ts | 60 +++
cli/services/telemetry/instrument.test.ts | 46 +++
cli/services/telemetry/instrument.ts | 11 +
cli/services/telemetry/posthog-shared.test.ts | 86 ++++
.../telemetry/posthog-usage-tracking.test.ts | 129 ++++++
.../telemetry/posthog-usage-tracking.ts | 93 +++++
cli/shared/telemetry/posthog-shared.ts | 32 ++
docs/aso-error-handling.md | 3 +
docs/aso-runtime-flows.md | 1 +
package-lock.json | 375 ++++++------------
package.json | 3 +-
14 files changed, 678 insertions(+), 260 deletions(-)
create mode 100644 cli/services/runtime/aso-config-service.test.ts
create mode 100644 cli/services/runtime/aso-config-service.ts
create mode 100644 cli/services/telemetry/posthog-shared.test.ts
create mode 100644 cli/services/telemetry/posthog-usage-tracking.test.ts
create mode 100644 cli/services/telemetry/posthog-usage-tracking.ts
create mode 100644 cli/shared/telemetry/posthog-shared.ts
diff --git a/.env.example b/.env.example
index 36546eb..2a91924 100644
--- a/.env.example
+++ b/.env.example
@@ -17,3 +17,7 @@
# ASO_POPULARITY_CACHE_TTL_HOURS=720
# ASO_APP_CACHE_TTL_HOURS=168
# ASO_OWNED_APP_DOC_REFRESH_MAX_AGE_HOURS=24
+
+# Optional PostHog runtime telemetry
+# ASO_POSTHOG_API_KEY=phc_xxx
+# ASO_POSTHOG_HOST=https://us.i.posthog.com
diff --git a/cli/cli.ts b/cli/cli.ts
index ff048df..e0f06ce 100644
--- a/cli/cli.ts
+++ b/cli/cli.ts
@@ -16,10 +16,18 @@ import {
isStdoutKeywordsRun,
toMachineReadableErrorMessage,
} from "./services/runtime/stdout-contract";
+import {
+ shutdownPostHog,
+ trackCliStarted,
+} from "./services/telemetry/posthog-usage-tracking";
assertSupportedNodeVersion();
const processArgs = process.argv?.slice(2) || [];
const stdoutKeywordsRun = isStdoutKeywordsRun(processArgs);
+const commandName =
+ typeof processArgs[0] === "string" && processArgs[0].trim()
+ ? processArgs[0].trim()
+ : "dashboard";
const isDebugEnabled = process.env.NODE_ENV == "development";
@@ -34,6 +42,7 @@ if (isDebugEnabled) {
}
async function main() {
+ trackCliStarted({ command: commandName });
checkVersionUpdateSync({ allowStdoutMessage: !stdoutKeywordsRun });
const parser = yargs(hideBin(process.argv))
@@ -51,14 +60,16 @@ async function main() {
"Use 'aso --help' to see available commands and options."
);
}
+ await shutdownPostHog();
process.exit(1);
})
.help();
await parser.parseAsync();
+ await shutdownPostHog();
}
-main().catch((err) => {
+main().catch(async (err) => {
let command = "unknown";
try {
@@ -81,5 +92,6 @@ main().catch((err) => {
command,
context: processedError,
});
+ await shutdownPostHog();
process.exitCode = 1;
});
diff --git a/cli/services/runtime/aso-config-service.test.ts b/cli/services/runtime/aso-config-service.test.ts
new file mode 100644
index 0000000..ecad38d
--- /dev/null
+++ b/cli/services/runtime/aso-config-service.test.ts
@@ -0,0 +1,81 @@
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { readAsoConfig, writeAsoConfig } from "./aso-config-service";
+
+describe("aso config service", () => {
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ for (const dirPath of tempDirs) {
+ fs.rmSync(dirPath, { recursive: true, force: true });
+ }
+ tempDirs.length = 0;
+ });
+
+ function createTempConfigPath(): string {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aso-config-test-"));
+ tempDirs.push(tempDir);
+ return path.join(tempDir, ".aso", "config.json");
+ }
+
+ it("returns empty config when file does not exist", () => {
+ const configPath = createTempConfigPath();
+
+ expect(readAsoConfig(configPath)).toEqual({});
+ });
+
+ it("writes and reads config values", () => {
+ const configPath = createTempConfigPath();
+
+ writeAsoConfig(
+ {
+ userId: "user-1",
+ featureFlag: true,
+ },
+ configPath
+ );
+
+ expect(readAsoConfig(configPath)).toEqual({
+ userId: "user-1",
+ featureFlag: true,
+ });
+ });
+
+ it("returns empty config for invalid json", () => {
+ const configPath = createTempConfigPath();
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
+ fs.writeFileSync(configPath, "{ invalid", "utf8");
+
+ expect(readAsoConfig(configPath)).toEqual({});
+ });
+
+ it("serves repeated reads from in-memory cache", () => {
+ const configPath = createTempConfigPath();
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
+ fs.writeFileSync(configPath, JSON.stringify({ userId: "first-user" }), "utf8");
+ const readSpy = jest.spyOn(fs, "readFileSync");
+
+ const firstRead = readAsoConfig(configPath);
+ fs.writeFileSync(configPath, JSON.stringify({ userId: "second-user" }), "utf8");
+ const secondRead = readAsoConfig(configPath);
+
+ expect(firstRead).toEqual({ userId: "first-user" });
+ expect(secondRead).toEqual({ userId: "first-user" });
+ expect(readSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("updates cache on write and protects cached state from caller mutation", () => {
+ const configPath = createTempConfigPath();
+ const readSpy = jest.spyOn(fs, "readFileSync");
+
+ writeAsoConfig({ userId: "cached-user" }, configPath);
+ const firstRead = readAsoConfig(configPath);
+ firstRead.userId = "mutated-by-caller";
+ const secondRead = readAsoConfig(configPath);
+
+ expect(readSpy).not.toHaveBeenCalled();
+ expect(secondRead).toEqual({ userId: "cached-user" });
+ });
+});
diff --git a/cli/services/runtime/aso-config-service.ts b/cli/services/runtime/aso-config-service.ts
new file mode 100644
index 0000000..ae58d2b
--- /dev/null
+++ b/cli/services/runtime/aso-config-service.ts
@@ -0,0 +1,60 @@
+import fs from "fs";
+import os from "os";
+import path from "path";
+
+const ASO_CONFIG_PATH = path.join(os.homedir(), ".aso", "config.json");
+const configCache = new Map();
+
+export type AsoConfig = Record;
+
+function normalizeConfigPath(configPath: string): string {
+ return path.resolve(configPath);
+}
+
+function cloneConfig(config: AsoConfig): AsoConfig {
+ return { ...config };
+}
+
+export function readAsoConfig(configPath = ASO_CONFIG_PATH): AsoConfig {
+ const resolvedPath = normalizeConfigPath(configPath);
+ const cached = configCache.get(resolvedPath);
+ if (cached) {
+ return cloneConfig(cached);
+ }
+
+ let loadedConfig: AsoConfig = {};
+
+ try {
+ if (fs.existsSync(resolvedPath)) {
+ const raw = fs.readFileSync(resolvedPath, "utf8");
+ const parsed = JSON.parse(raw) as unknown;
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ loadedConfig = parsed as AsoConfig;
+ }
+ }
+ } catch {
+ loadedConfig = {};
+ }
+
+ configCache.set(resolvedPath, cloneConfig(loadedConfig));
+ return cloneConfig(loadedConfig);
+}
+
+export function writeAsoConfig(
+ config: AsoConfig,
+ configPath = ASO_CONFIG_PATH
+): void {
+ const resolvedPath = normalizeConfigPath(configPath);
+ const dirPath = path.dirname(resolvedPath);
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
+ }
+
+ const tempPath = `${resolvedPath}.tmp`;
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), {
+ encoding: "utf8",
+ mode: 0o600,
+ });
+ fs.renameSync(tempPath, resolvedPath);
+ configCache.set(resolvedPath, cloneConfig(config));
+}
diff --git a/cli/services/telemetry/instrument.test.ts b/cli/services/telemetry/instrument.test.ts
index 7445cce..37d4fb9 100644
--- a/cli/services/telemetry/instrument.test.ts
+++ b/cli/services/telemetry/instrument.test.ts
@@ -1,9 +1,14 @@
describe("telemetry instrument", () => {
+ const defaultPosthogApiKey = "phc_CjK5coJt6fxtXseg8XgkU8dMfXPur3JgabQh5454opmQ";
const originalNodeEnv = process.env.NODE_ENV;
const originalBugsnagApiKey = process.env.BUGSNAG_API_KEY;
+ const originalPosthogApiKey = process.env.ASO_POSTHOG_API_KEY;
+ const originalPosthogHost = process.env.ASO_POSTHOG_HOST;
beforeEach(() => {
delete process.env.BUGSNAG_API_KEY;
+ delete process.env.ASO_POSTHOG_API_KEY;
+ delete process.env.ASO_POSTHOG_HOST;
});
afterEach(() => {
@@ -13,6 +18,16 @@ describe("telemetry instrument", () => {
} else {
process.env.BUGSNAG_API_KEY = originalBugsnagApiKey;
}
+ if (originalPosthogApiKey === undefined) {
+ delete process.env.ASO_POSTHOG_API_KEY;
+ } else {
+ process.env.ASO_POSTHOG_API_KEY = originalPosthogApiKey;
+ }
+ if (originalPosthogHost === undefined) {
+ delete process.env.ASO_POSTHOG_HOST;
+ } else {
+ process.env.ASO_POSTHOG_HOST = originalPosthogHost;
+ }
jest.resetModules();
jest.clearAllMocks();
});
@@ -20,7 +35,9 @@ describe("telemetry instrument", () => {
it("initializes bugsnag in development mode", async () => {
process.env.NODE_ENV = "development";
const initializeBugsnag = jest.fn();
+ const initializePostHog = jest.fn();
jest.doMock("../../shared/telemetry/bugsnag-shared", () => ({ initializeBugsnag }));
+ jest.doMock("../../shared/telemetry/posthog-shared", () => ({ initializePostHog }));
await import("./instrument");
@@ -29,12 +46,18 @@ describe("telemetry instrument", () => {
isDevelopment: true,
appVersion: version,
});
+ expect(initializePostHog).toHaveBeenCalledWith({
+ isDevelopment: true,
+ apiKey: defaultPosthogApiKey,
+ });
});
it("initializes bugsnag in production mode", async () => {
process.env.NODE_ENV = "production";
const initializeBugsnag = jest.fn();
+ const initializePostHog = jest.fn();
jest.doMock("../../shared/telemetry/bugsnag-shared", () => ({ initializeBugsnag }));
+ jest.doMock("../../shared/telemetry/posthog-shared", () => ({ initializePostHog }));
await import("./instrument");
@@ -43,5 +66,28 @@ describe("telemetry instrument", () => {
isDevelopment: false,
appVersion: version,
});
+ expect(initializePostHog).toHaveBeenCalledWith({
+ isDevelopment: false,
+ apiKey: defaultPosthogApiKey,
+ });
+ });
+
+ it("passes posthog env overrides into telemetry startup", async () => {
+ process.env.NODE_ENV = "production";
+ process.env.ASO_POSTHOG_API_KEY = "phc_test_key";
+ process.env.ASO_POSTHOG_HOST = "https://eu.i.posthog.com";
+
+ const initializeBugsnag = jest.fn();
+ const initializePostHog = jest.fn();
+ jest.doMock("../../shared/telemetry/bugsnag-shared", () => ({ initializeBugsnag }));
+ jest.doMock("../../shared/telemetry/posthog-shared", () => ({ initializePostHog }));
+
+ await import("./instrument");
+
+ expect(initializePostHog).toHaveBeenCalledWith({
+ isDevelopment: false,
+ apiKey: "phc_test_key",
+ host: "https://eu.i.posthog.com",
+ });
});
});
diff --git a/cli/services/telemetry/instrument.ts b/cli/services/telemetry/instrument.ts
index d003714..4ae78e4 100644
--- a/cli/services/telemetry/instrument.ts
+++ b/cli/services/telemetry/instrument.ts
@@ -1,5 +1,8 @@
import { version } from "../../../package.json";
import { initializeBugsnag } from "../../shared/telemetry/bugsnag-shared";
+import { initializePostHog } from "../../shared/telemetry/posthog-shared";
+
+const DEFAULT_POSTHOG_API_KEY = "phc_CjK5coJt6fxtXseg8XgkU8dMfXPur3JgabQh5454opmQ";
const isDevelopment = process.env.NODE_ENV == "development";
const bugsnagApiKey = process.env.BUGSNAG_API_KEY?.trim();
@@ -8,3 +11,11 @@ initializeBugsnag({
...(bugsnagApiKey ? { apiKey: bugsnagApiKey } : {}),
appVersion: version,
});
+
+const posthogApiKey = process.env.ASO_POSTHOG_API_KEY?.trim() || DEFAULT_POSTHOG_API_KEY;
+const posthogHost = process.env.ASO_POSTHOG_HOST?.trim();
+initializePostHog({
+ isDevelopment,
+ apiKey: posthogApiKey,
+ ...(posthogHost ? { host: posthogHost } : {}),
+});
diff --git a/cli/services/telemetry/posthog-shared.test.ts b/cli/services/telemetry/posthog-shared.test.ts
new file mode 100644
index 0000000..190a701
--- /dev/null
+++ b/cli/services/telemetry/posthog-shared.test.ts
@@ -0,0 +1,86 @@
+jest.mock("posthog-node", () => ({
+ PostHog: jest.fn(() => ({
+ identify: jest.fn(),
+ capture: jest.fn(),
+ shutdown: jest.fn(),
+ })),
+}));
+
+describe("posthog-shared", () => {
+ function getPostHogCtor(): jest.Mock {
+ return (jest.requireMock("posthog-node") as { PostHog: jest.Mock }).PostHog;
+ }
+
+ beforeEach(() => {
+ jest.resetModules();
+ jest.clearAllMocks();
+ });
+
+ it("does not start in development mode", async () => {
+ const { initializePostHog, getPostHogClient } = await import(
+ "../../shared/telemetry/posthog-shared"
+ );
+ const PostHog = getPostHogCtor();
+
+ initializePostHog({
+ isDevelopment: true,
+ apiKey: "phc_test",
+ });
+
+ expect(PostHog).not.toHaveBeenCalled();
+ expect(getPostHogClient()).toBeNull();
+ });
+
+ it("does not start when api key is blank", async () => {
+ const { initializePostHog, getPostHogClient } = await import(
+ "../../shared/telemetry/posthog-shared"
+ );
+ const PostHog = getPostHogCtor();
+
+ initializePostHog({
+ isDevelopment: false,
+ apiKey: " ",
+ });
+
+ expect(PostHog).not.toHaveBeenCalled();
+ expect(getPostHogClient()).toBeNull();
+ });
+
+ it("starts once without host override when host is not provided", async () => {
+ const { initializePostHog, getPostHogClient } = await import(
+ "../../shared/telemetry/posthog-shared"
+ );
+ const PostHog = getPostHogCtor();
+
+ initializePostHog({
+ isDevelopment: false,
+ apiKey: "phc_test",
+ });
+ initializePostHog({
+ isDevelopment: false,
+ apiKey: "phc_test_again",
+ host: "https://eu.i.posthog.com",
+ });
+
+ expect(PostHog).toHaveBeenCalledTimes(1);
+ expect(PostHog).toHaveBeenCalledWith("phc_test");
+ expect(getPostHogClient()).toBe(PostHog.mock.results[0]?.value);
+ });
+
+ it("passes host override only when provided", async () => {
+ const { initializePostHog } = await import(
+ "../../shared/telemetry/posthog-shared"
+ );
+ const PostHog = getPostHogCtor();
+
+ initializePostHog({
+ isDevelopment: false,
+ apiKey: "phc_test",
+ host: "https://eu.i.posthog.com",
+ });
+
+ expect(PostHog).toHaveBeenCalledWith("phc_test", {
+ host: "https://eu.i.posthog.com",
+ });
+ });
+});
diff --git a/cli/services/telemetry/posthog-usage-tracking.test.ts b/cli/services/telemetry/posthog-usage-tracking.test.ts
new file mode 100644
index 0000000..ed10054
--- /dev/null
+++ b/cli/services/telemetry/posthog-usage-tracking.test.ts
@@ -0,0 +1,129 @@
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { version } from "../../../package.json";
+
+jest.mock("../../shared/telemetry/posthog-shared", () => ({
+ getPostHogClient: jest.fn(),
+}));
+
+import { getPostHogClient } from "../../shared/telemetry/posthog-shared";
+import {
+ getOrCreatePostHogUserId,
+ shutdownPostHog,
+ trackCliStarted,
+} from "./posthog-usage-tracking";
+
+describe("posthog usage tracking", () => {
+ const mockedGetPostHogClient = jest.mocked(getPostHogClient);
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ mockedGetPostHogClient.mockReset();
+ for (const dirPath of tempDirs) {
+ fs.rmSync(dirPath, { recursive: true, force: true });
+ }
+ tempDirs.length = 0;
+ });
+
+ function createTempConfigPath(): string {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aso-posthog-test-"));
+ tempDirs.push(tempDir);
+ return path.join(tempDir, ".aso", "config.json");
+ }
+
+ it("creates and persists a stable user id when config is missing", () => {
+ const configPath = createTempConfigPath();
+
+ const userId = getOrCreatePostHogUserId(configPath);
+
+ expect(userId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
+ const payload = JSON.parse(fs.readFileSync(configPath, "utf8")) as {
+ userId: string;
+ };
+ expect(payload.userId).toBe(userId);
+ });
+
+ it("reuses the persisted user id from config", () => {
+ const configPath = createTempConfigPath();
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
+ fs.writeFileSync(
+ configPath,
+ JSON.stringify({ userId: "existing-user-id", other: "value" }),
+ "utf8"
+ );
+
+ const userId = getOrCreatePostHogUserId(configPath);
+
+ expect(userId).toBe("existing-user-id");
+ const payload = JSON.parse(fs.readFileSync(configPath, "utf8")) as {
+ userId: string;
+ other: string;
+ };
+ expect(payload).toEqual({
+ userId: "existing-user-id",
+ other: "value",
+ });
+ });
+
+ it("tracks cli_started with identify and first/last seen properties", () => {
+ const identify = jest.fn();
+ const capture = jest.fn();
+ mockedGetPostHogClient.mockReturnValue({
+ identify,
+ capture,
+ } as any);
+
+ trackCliStarted({
+ distinctId: "test-user-id",
+ command: "keywords",
+ now: new Date("2026-04-26T08:00:00.000Z"),
+ });
+
+ expect(identify).toHaveBeenCalledWith({
+ distinctId: "test-user-id",
+ properties: {
+ cli_version: version,
+ node_version: process.version,
+ command: "keywords",
+ },
+ });
+ expect(capture).toHaveBeenCalledWith({
+ distinctId: "test-user-id",
+ event: "cli_started",
+ properties: {
+ $set_once: {
+ first_seen_at: "2026-04-26T08:00:00.000Z",
+ },
+ $set: {
+ last_seen_at: "2026-04-26T08:00:00.000Z",
+ cli_version: version,
+ node_version: process.version,
+ command: "keywords",
+ },
+ },
+ });
+ });
+
+ it("shuts down posthog before process exit", async () => {
+ const shutdown = jest.fn().mockResolvedValue(undefined);
+ mockedGetPostHogClient.mockReturnValue({
+ shutdown,
+ } as any);
+
+ await shutdownPostHog(1234);
+
+ expect(shutdown).toHaveBeenCalledWith(1234);
+ });
+
+ it("ignores posthog shutdown errors", async () => {
+ const shutdown = jest.fn(() => {
+ throw new Error("shutdown failed");
+ });
+ mockedGetPostHogClient.mockReturnValue({
+ shutdown,
+ } as any);
+
+ await expect(shutdownPostHog()).resolves.toBeUndefined();
+ });
+});
diff --git a/cli/services/telemetry/posthog-usage-tracking.ts b/cli/services/telemetry/posthog-usage-tracking.ts
new file mode 100644
index 0000000..e1e0582
--- /dev/null
+++ b/cli/services/telemetry/posthog-usage-tracking.ts
@@ -0,0 +1,93 @@
+import { randomUUID } from "crypto";
+import { version } from "../../../package.json";
+import { getPostHogClient } from "../../shared/telemetry/posthog-shared";
+import {
+ readAsoConfig,
+ writeAsoConfig,
+} from "../runtime/aso-config-service";
+
+type PostHogWithPromiseShutdown = {
+ shutdown: (shutdownTimeoutMs?: number) => Promise | void;
+};
+
+export function getOrCreatePostHogUserId(configPath?: string): string {
+ const config = readAsoConfig(configPath);
+ const persistedUserId =
+ typeof config.userId === "string" ? config.userId.trim() : "";
+
+ if (persistedUserId) {
+ return persistedUserId;
+ }
+
+ const generatedUserId = randomUUID();
+
+ try {
+ writeAsoConfig(
+ {
+ ...config,
+ userId: generatedUserId,
+ },
+ configPath
+ );
+ } catch {
+ return generatedUserId;
+ }
+
+ return generatedUserId;
+}
+
+export function trackCliStarted(options?: {
+ command?: string;
+ now?: Date;
+ distinctId?: string;
+}): void {
+ const client = getPostHogClient();
+ if (!client) return;
+
+ const distinctId = options?.distinctId || getOrCreatePostHogUserId();
+ const timestamp = (options?.now || new Date()).toISOString();
+ const command = options?.command?.trim();
+
+ const userProperties = {
+ cli_version: version,
+ node_version: process.version,
+ ...(command ? { command } : {}),
+ };
+
+ client.identify({
+ distinctId,
+ properties: userProperties,
+ });
+
+ client.capture({
+ distinctId,
+ event: "cli_started",
+ properties: {
+ $set_once: {
+ first_seen_at: timestamp,
+ },
+ $set: {
+ last_seen_at: timestamp,
+ ...userProperties,
+ },
+ },
+ });
+}
+
+export async function shutdownPostHog(shutdownTimeoutMs = 5000): Promise {
+ const client = getPostHogClient() as PostHogWithPromiseShutdown | null;
+ if (!client) return;
+
+ try {
+ const result = client.shutdown(shutdownTimeoutMs);
+ if (
+ result &&
+ typeof result === "object" &&
+ typeof (result as Promise).then === "function"
+ ) {
+ await result;
+ }
+ } catch {
+ return;
+ }
+}
diff --git a/cli/shared/telemetry/posthog-shared.ts b/cli/shared/telemetry/posthog-shared.ts
new file mode 100644
index 0000000..c2ddaef
--- /dev/null
+++ b/cli/shared/telemetry/posthog-shared.ts
@@ -0,0 +1,32 @@
+import { PostHog } from "posthog-node";
+
+let started = false;
+let posthogClient: PostHog | null = null;
+
+function normalizeOptional(value: string | undefined): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed === "" ? undefined : trimmed;
+}
+
+export function initializePostHog(options: {
+ isDevelopment: boolean;
+ apiKey: string;
+ host?: string;
+}): void {
+ if (started) return;
+ started = true;
+
+ if (options.isDevelopment) return;
+
+ const apiKey = normalizeOptional(options.apiKey);
+ if (!apiKey) return;
+
+ const host = normalizeOptional(options.host);
+
+ posthogClient = host ? new PostHog(apiKey, { host }) : new PostHog(apiKey);
+}
+
+export function getPostHogClient(): PostHog | null {
+ return posthogClient;
+}
diff --git a/docs/aso-error-handling.md b/docs/aso-error-handling.md
index 52c666e..38e99dc 100644
--- a/docs/aso-error-handling.md
+++ b/docs/aso-error-handling.md
@@ -71,6 +71,9 @@ Define failure boundaries, retry rules, and recovery behavior across CLI, dashbo
- Contract-drift events are deduped for `15` minutes per unique signature (`provider + operation + endpoint + drift kind + status bucket`) to reduce alert spam during repeated failures.
- Bugsnag redaction is centralized at SDK startup via global `redactedKeys` and `onError` sanitization before event delivery (including nested metadata and keychain command-arg payloads such as `spawnargs` values after `-w`).
- Runtime telemetry startup resolves Bugsnag API key in this order: explicit runtime option, runtime `BUGSNAG_API_KEY`, then packaged fallback key injected in release CI from GitHub Secret `BUGSNAG_API_KEY`; startup is skipped with a warning only when all are missing.
+- Runtime telemetry startup resolves PostHog settings before shared init: API key from `ASO_POSTHOG_API_KEY` (or packaged fallback when unset) plus optional `ASO_POSTHOG_HOST` override; `posthog-shared` passes host only when explicitly provided and otherwise relies on the PostHog SDK default host, and initialization is skipped in development mode.
+- CLI usage tracking persists a stable PostHog `distinctId` in `~/.aso/config.json` (`userId`) and emits `cli_started` with `$set_once.first_seen_at` plus `$set.last_seen_at/cli_version/node_version` on each process start.
+- CLI process exit paths explicitly call PostHog shutdown before exiting so short-lived command runs flush queued analytics events.
- Release pipeline enforces packaged-key integrity by requiring the secret, replacing exactly one source placeholder, and failing if placeholder text remains in built artifacts.
- Dashboard Bugsnag startup enables browser session tracking and includes `request`/`navigation` breadcrumbs; CLI/MCP keep stricter defaults.
- Dashboard server reports failures with structured metadata (path, phase, counts).
diff --git a/docs/aso-runtime-flows.md b/docs/aso-runtime-flows.md
index a32d8f8..3ad8dc2 100644
--- a/docs/aso-runtime-flows.md
+++ b/docs/aso-runtime-flows.md
@@ -20,6 +20,7 @@ Runtime flow contracts across CLI commands, local dashboard API, and ASO service
- `aso keywords "..." --stdout`: machine-friendly mode; emits JSON-only stdout, attempts silent reauth, and fails when interactive user input is required.
- `aso auth`: run only Apple Search Ads reauthentication.
- `aso reset-credentials`: clear saved ASO keychain credentials and local cookies.
+- Any `aso` process start emits PostHog `cli_started` telemetry using a persisted user id at `~/.aso/config.json` (`userId`), then flushes telemetry on normal/error exits.
- MCP `aso_evaluate_keywords`: accept explicit keywords (max 100), run `aso keywords "" --stdout`, return evaluated keyword results.
- Dashboard API mutations: app add (single-item POST; UI may batch multiple selections), app delete, keyword add/delete, keyword favorite toggle, auth start.
diff --git a/package-lock.json b/package-lock.json
index 5706a72..2ce56cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aso-cli",
- "version": "0.5.0",
+ "version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aso-cli",
- "version": "0.5.0",
+ "version": "0.8.0",
"license": "MIT",
"dependencies": {
"@bugsnag/js": "^8.6.0",
@@ -17,6 +17,7 @@
"dotenv": "^16.6.1",
"inquirer": "^8.2.5",
"ora": "^8.2.0",
+ "posthog-node": "^5.30.4",
"winston": "^3.17.0",
"yargs": "^17.7.2",
"zod": "4.1.9",
@@ -117,6 +118,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -728,34 +730,6 @@
"node": ">=0.1.90"
}
},
- "node_modules/@cspotcode/source-map-support": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
- "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/trace-mapping": "0.3.9"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.9",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
- "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.0.3",
- "@jridgewell/sourcemap-codec": "^1.4.10"
- }
- },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -844,6 +818,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -867,6 +842,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1359,9 +1335,9 @@
}
},
"node_modules/@hono/node-server": {
- "version": "1.19.11",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
- "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1988,9 +1964,9 @@
}
},
"node_modules/@jest/reporters/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2571,6 +2547,21 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@posthog/core": {
+ "version": "1.27.5",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.27.5.tgz",
+ "integrity": "sha512-sYCcUDuYKumYTjwGqGCPT8aUy086v9PKw5wD+UXCRSfCsxWy5R/ic6W13kGTn4O5B2cD1V19wJv19oIH5kHUiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "1.372.1"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.372.1",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.372.1.tgz",
+ "integrity": "sha512-yl2x2HgtdhFk8bvf6HuRSDzXnKmKGrzNxUahKvA/0mcwheweINvmWy5MsN55NevrcCrNXA6m8GPHS9o/y1mn4A==",
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -3041,42 +3032,6 @@
}
}
},
- "node_modules/@tsconfig/node10": {
- "version": "1.0.12",
- "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
- "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/@tsconfig/node12": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
- "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/@tsconfig/node14": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
- "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/@tsconfig/node16": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
- "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3093,8 +3048,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3260,6 +3214,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3277,6 +3232,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3287,6 +3243,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3648,36 +3605,6 @@
"node": ">= 0.6"
}
},
- "node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-walk": {
- "version": "8.3.5",
- "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
- "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "acorn": "^8.11.0"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -3773,9 +3700,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3785,15 +3712,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/arg": {
- "version": "4.1.3",
- "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
- "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -3837,14 +3755,15 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.13.6",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
- "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
+ "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
- "proxy-from-env": "^1.1.0"
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/axios-mock-adapter": {
@@ -4197,9 +4116,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4227,6 +4146,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4914,15 +4834,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/create-require": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
- "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5111,25 +5022,12 @@
"node": ">=8"
}
},
- "node_modules/diff": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
- "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=0.3.1"
- }
- },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
@@ -5460,6 +5358,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5655,9 +5554,9 @@
"license": "MIT"
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -5938,9 +5837,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
- "version": "4.7.8",
- "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
- "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "version": "4.7.9",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
+ "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6009,10 +5908,11 @@
}
},
"node_modules/hono": {
- "version": "4.12.7",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
- "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
+ "version": "4.12.15",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz",
+ "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -6641,6 +6541,7 @@
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "30.3.0",
"@jest/types": "30.3.0",
@@ -6997,9 +6898,9 @@
}
},
"node_modules/jest-config/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8096,9 +7997,9 @@
}
},
"node_modules/jest-runtime/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8771,6 +8672,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -8887,9 +8789,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -8979,7 +8881,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -9726,9 +9627,9 @@
"license": "ISC"
},
"node_modules/path-to-regexp": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
- "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -9743,9 +9644,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9835,9 +9736,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@@ -9863,6 +9764,26 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/posthog-node": {
+ "version": "5.30.4",
+ "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.30.4.tgz",
+ "integrity": "sha512-NjEgLdQwff9aPssHgmDYYDe5EbMsR33k327g/hC7RM3qbaqWio559TqAaVjP+Ks8c61liP0Vhr90lr4dpO48OQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/core": "1.27.5"
+ },
+ "engines": {
+ "node": "^20.20.0 || >=22.22.0"
+ },
+ "peerDependencies": {
+ "rxjs": "^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rxjs": {
+ "optional": true
+ }
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -9912,7 +9833,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -9928,7 +9848,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -9950,10 +9869,13 @@
}
},
"node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
},
"node_modules/pump": {
"version": "3.0.4",
@@ -10077,6 +9999,7 @@
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10087,6 +10010,7 @@
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10099,8 +10023,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -11232,52 +11155,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/ts-node": {
- "version": "10.9.2",
- "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
- "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@cspotcode/source-map-support": "^0.8.0",
- "@tsconfig/node10": "^1.0.7",
- "@tsconfig/node12": "^1.0.7",
- "@tsconfig/node14": "^1.0.0",
- "@tsconfig/node16": "^1.0.2",
- "acorn": "^8.4.1",
- "acorn-walk": "^8.1.1",
- "arg": "^4.1.0",
- "create-require": "^1.1.0",
- "diff": "^4.0.1",
- "make-error": "^1.1.1",
- "v8-compile-cache-lib": "^3.0.1",
- "yn": "3.1.1"
- },
- "bin": {
- "ts-node": "dist/bin.js",
- "ts-node-cwd": "dist/bin-cwd.js",
- "ts-node-esm": "dist/bin-esm.js",
- "ts-node-script": "dist/bin-script.js",
- "ts-node-transpile-only": "dist/bin-transpile.js",
- "ts-script": "dist/bin-script-deprecated.js"
- },
- "peerDependencies": {
- "@swc/core": ">=1.2.50",
- "@swc/wasm": ">=1.2.50",
- "@types/node": "*",
- "typescript": ">=2.7"
- },
- "peerDependenciesMeta": {
- "@swc/core": {
- "optional": true
- },
- "@swc/wasm": {
- "optional": true
- }
- }
- },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -11345,6 +11222,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11378,9 +11256,9 @@
}
},
"node_modules/undici": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
- "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
+ "version": "6.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
+ "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11475,15 +11353,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
- "node_modules/v8-compile-cache-lib": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
- "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -11520,11 +11389,12 @@
}
},
"node_modules/vite": {
- "version": "7.3.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
- "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11947,9 +11817,9 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
@@ -11989,18 +11859,6 @@
"node": ">=12"
}
},
- "node_modules/yn": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
- "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -12019,6 +11877,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.9.tgz",
"integrity": "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index d7f0949..584f9e2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aso-cli",
- "version": "0.8.0",
+ "version": "0.9.0",
"description": "Local App Store Optimization CLI",
"main": "cli/dist/cli.js",
"bin": {
@@ -75,6 +75,7 @@
"dotenv": "^16.6.1",
"inquirer": "^8.2.5",
"ora": "^8.2.0",
+ "posthog-node": "^5.30.4",
"winston": "^3.17.0",
"yargs": "^17.7.2",
"zod": "4.1.9",
From b8fea4cb829a7471ef87b52878761ac831ec221b Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Wed, 29 Apr 2026 12:05:07 +0300
Subject: [PATCH 06/55] match auth handling on dashboard startup with add new
keyword flow
---
cli/dashboard-server/server.routes.test.ts | 15 ++
cli/dashboard-server/server.ts | 17 ++
.../startup-refresh-manager.test.ts | 57 +++++
.../startup-refresh-manager.ts | 36 ++-
cli/dashboard-ui/App.tsx | 152 ++++++++++--
cli/dashboard-ui/app.auth.test.tsx | 218 ++++++++++++++++++
cli/dashboard-ui/hooks/use-auth-flow.ts | 46 +++-
cli/dashboard-ui/styles.css | 30 ++-
docs/aso-error-handling.md | 3 +
docs/aso-runtime-flows.md | 16 +-
10 files changed, 544 insertions(+), 46 deletions(-)
diff --git a/cli/dashboard-server/server.routes.test.ts b/cli/dashboard-server/server.routes.test.ts
index 91aa61d..fd807ab 100644
--- a/cli/dashboard-server/server.routes.test.ts
+++ b/cli/dashboard-server/server.routes.test.ts
@@ -341,6 +341,21 @@ describe("dashboard server routes", () => {
);
});
+ it("starts startup refresh on demand", async () => {
+ const response = await request({
+ method: "POST",
+ path: "/api/aso/refresh/start",
+ });
+
+ expect(response.statusCode).toBe(202);
+ expect(response.json?.success).toBe(true);
+ expect(response.json?.data).toEqual(
+ expect.objectContaining({
+ status: expect.any(String),
+ })
+ );
+ });
+
it("returns apps sorted and ensures default research app", async () => {
mockListOwnedApps.mockReturnValue([
{ id: "2", kind: "owned", name: "Beta", lastFetchedAt: null } as any,
diff --git a/cli/dashboard-server/server.ts b/cli/dashboard-server/server.ts
index 1cc968b..8dbd437 100644
--- a/cli/dashboard-server/server.ts
+++ b/cli/dashboard-server/server.ts
@@ -141,12 +141,17 @@ const startupRefreshManager = createStartupRefreshManager({
context: "startup-refresh",
});
},
+ isAuthReauthRequiredError: isAsoAuthReauthRequiredError,
});
function getStartupRefreshState(): StartupRefreshState {
return startupRefreshManager.getState();
}
+function startStartupRefresh(): void {
+ startupRefreshManager.start();
+}
+
const dashboardAuthStateManager = createDashboardAuthStateManager({
reAuthenticate: (options) => asoAuthService.reAuthenticate(options),
onError: (error) => {
@@ -203,6 +208,11 @@ function handleApiAsoRefreshStatusGet(res: http.ServerResponse): void {
sendJson(res, 200, { success: true, data: getStartupRefreshState() });
}
+function handleApiAsoRefreshStartPost(res: http.ServerResponse): void {
+ startStartupRefresh();
+ sendJson(res, 202, { success: true, data: getStartupRefreshState() });
+}
+
function handleApiAsoAuthStartPost(res: http.ServerResponse): void {
if (!hasInteractiveTerminal()) {
sendApiError(
@@ -351,6 +361,13 @@ export function createServerRequestHandler(): http.RequestListener {
return;
}
+ if (req.method === "POST" && pathname === "/api/aso/refresh/start") {
+ runAsForegroundMutationSync(() => {
+ handleApiAsoRefreshStartPost(res);
+ });
+ return;
+ }
+
if (req.method === "POST" && pathname === "/api/aso/auth/start") {
runAsForegroundMutationSync(() => {
handleApiAsoAuthStartPost(res);
diff --git a/cli/dashboard-server/startup-refresh-manager.test.ts b/cli/dashboard-server/startup-refresh-manager.test.ts
index 5c45da9..0fb0f40 100644
--- a/cli/dashboard-server/startup-refresh-manager.test.ts
+++ b/cli/dashboard-server/startup-refresh-manager.test.ts
@@ -190,6 +190,7 @@ describe("startup-refresh-manager", () => {
expect(state.counters.eligibleKeywordCount).toBe(3);
expect(state.counters.refreshedKeywordCount).toBe(3);
expect(state.counters.failedKeywordCount).toBe(0);
+ expect(state.requiresReauthentication).toBe(false);
expect(enrichCalls).toHaveLength(3);
expect(errors).toHaveLength(0);
});
@@ -228,6 +229,62 @@ describe("startup-refresh-manager", () => {
expect(state.status).toBe("failed");
expect(state.counters.refreshedKeywordCount).toBe(0);
expect(state.counters.failedKeywordCount).toBe(1);
+ expect(state.requiresReauthentication).toBe(false);
expect(errors).toHaveLength(1);
});
+
+ it("marks auth-required failures and allows restart after failure", async () => {
+ let attempt = 0;
+
+ const manager = createStartupRefreshManager({
+ country: "US",
+ listKeywords: () => [
+ buildKeyword({
+ keyword: "k1",
+ normalizedKeyword: "k1",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ ],
+ listAppKeywords: () => [buildAssociation({ keyword: "k1", appId: "app-1" })],
+ listAssociatedAppIds: () => new Set(["app-1"]),
+ listOrderRelevantAppIds: () => new Set(["app-1"]),
+ enrichKeywords: async () => {
+ attempt += 1;
+ if (attempt === 1) {
+ throw new Error(
+ "Apple Search Ads session expired. Reauthentication is required."
+ );
+ }
+ },
+ isForegroundBusy: () => false,
+ isAuthReauthRequiredError: (error) =>
+ error instanceof Error &&
+ error.message.includes("Reauthentication is required"),
+ nowMs: () => Date.parse("2026-03-07T00:00:00.000Z"),
+ sleep: async () => {},
+ keywordBatchSize: 25,
+ });
+
+ manager.start();
+ await waitForManagerToFinish(manager);
+
+ expect(manager.getState()).toEqual(
+ expect.objectContaining({
+ status: "failed",
+ requiresReauthentication: true,
+ })
+ );
+
+ await Promise.resolve();
+ manager.start();
+ await waitForManagerToFinish(manager);
+
+ expect(manager.getState()).toEqual(
+ expect.objectContaining({
+ status: "completed",
+ requiresReauthentication: false,
+ })
+ );
+ expect(attempt).toBe(2);
+ });
});
diff --git a/cli/dashboard-server/startup-refresh-manager.ts b/cli/dashboard-server/startup-refresh-manager.ts
index c819cad..b97d03b 100644
--- a/cli/dashboard-server/startup-refresh-manager.ts
+++ b/cli/dashboard-server/startup-refresh-manager.ts
@@ -19,6 +19,7 @@ export type StartupRefreshState = {
startedAt: string | null;
finishedAt: string | null;
lastError: string | null;
+ requiresReauthentication: boolean;
counters: StartupRefreshCounters;
};
@@ -43,6 +44,7 @@ type StartupRefreshDeps = {
) => Promise;
isForegroundBusy: () => boolean;
reportError?: (error: unknown, metadata: Record) => void;
+ isAuthReauthRequiredError?: (error: unknown) => boolean;
nowMs?: () => number;
sleep?: (ms: number) => Promise;
keywordBatchSize?: number;
@@ -124,6 +126,7 @@ function initialState(): StartupRefreshState {
startedAt: null,
finishedAt: null,
lastError: null,
+ requiresReauthentication: false,
counters: initialCounters(),
};
}
@@ -138,11 +141,15 @@ function errorToMessage(error: unknown): string {
async function withOneRetry(
operation: () => Promise,
- sleep: (ms: number) => Promise
+ sleep: (ms: number) => Promise,
+ shouldRetry?: (error: unknown) => boolean
): Promise {
try {
await operation();
- } catch {
+ } catch (error) {
+ if (shouldRetry?.(error) === false) {
+ throw error;
+ }
await sleep(RETRY_DELAY_MS);
await operation();
}
@@ -166,6 +173,8 @@ export function createStartupRefreshManager(
const setFailure = (error: unknown, metadata: Record) => {
if (!state.lastError) {
state.lastError = errorToMessage(error);
+ state.requiresReauthentication =
+ deps.isAuthReauthRequiredError?.(error) === true;
}
deps.reportError?.(error, metadata);
};
@@ -189,7 +198,7 @@ export function createStartupRefreshManager(
try {
await withOneRetry(async () => {
await deps.enrichKeywords(deps.country, batch);
- }, sleep);
+ }, sleep, (error) => deps.isAuthReauthRequiredError?.(error) !== true);
state.counters.refreshedKeywordCount += batch.length;
} catch (error) {
state.counters.failedKeywordCount += batch.length;
@@ -208,6 +217,7 @@ export function createStartupRefreshManager(
startedAt: new Date(nowMs()).toISOString(),
finishedAt: null,
lastError: null,
+ requiresReauthentication: false,
counters: initialCounters(),
};
@@ -223,14 +233,18 @@ export function createStartupRefreshManager(
return {
start: () => {
if (runPromise) return;
- runPromise = run().catch((error) => {
- setFailure(error, { phase: "startup-refresh-unhandled" });
- state = {
- ...state,
- status: "failed",
- finishedAt: new Date(nowMs()).toISOString(),
- };
- });
+ runPromise = run()
+ .catch((error) => {
+ setFailure(error, { phase: "startup-refresh-unhandled" });
+ state = {
+ ...state,
+ status: "failed",
+ finishedAt: new Date(nowMs()).toISOString(),
+ };
+ })
+ .finally(() => {
+ runPromise = null;
+ });
},
getState: () => ({
...state,
diff --git a/cli/dashboard-ui/App.tsx b/cli/dashboard-ui/App.tsx
index c1fe5ab..1977361 100644
--- a/cli/dashboard-ui/App.tsx
+++ b/cli/dashboard-ui/App.tsx
@@ -191,6 +191,7 @@ type StartupRefreshStatusPayload = {
startedAt: string | null;
finishedAt: string | null;
lastError: string | null;
+ requiresReauthentication: boolean;
counters: {
eligibleKeywordCount: number;
refreshedKeywordCount: number;
@@ -315,6 +316,8 @@ export function App() {
const [hasCachedData, setHasCachedData] = useState(false);
const [isAddingKeywords, setIsAddingKeywords] = useState(false);
const [isRetryingFailedKeywords, setIsRetryingFailedKeywords] = useState(false);
+ const [isRestartingStartupRefresh, setIsRestartingStartupRefresh] =
+ useState(false);
const [loadingText, setLoadingText] = useState("");
const [errorText, setErrorText] = useState("");
const [successText, setSuccessText] = useState("");
@@ -343,6 +346,8 @@ export function App() {
const selectedAppIdRef = useRef(selectedAppId);
const autoRetryInFlightRef = useRef(false);
const startupAppSyncAtRef = useRef(null);
+ const resumeStartupRefreshAfterAuthRef = useRef(false);
+ const startupRefreshAuthAttemptKeyRef = useRef(null);
const appById = useMemo(
() => new Map(apps.map((app) => [app.id, app])),
@@ -396,9 +401,11 @@ export function App() {
pendingAddContext,
setPendingAddContext,
openAuthModalForPendingAdd,
+ requestStartupRefreshReauthentication,
startReauthentication,
authCheckLoadingText,
authStatusLabel,
+ activeAuthContext,
canStartReauth,
showReauthButton,
} = useAuthFlow({
@@ -446,6 +453,24 @@ export function App() {
return list;
}, []);
+ const restartStartupRefresh = useCallback(async (): Promise => {
+ try {
+ setIsRestartingStartupRefresh(true);
+ const data = await apiWrite(
+ "POST",
+ "/api/aso/refresh/start",
+ {}
+ );
+ setStartupRefreshState(data);
+ } catch (error) {
+ setErrorText(
+ toActionableErrorMessage(error, "Failed to restart background refresh")
+ );
+ } finally {
+ setIsRestartingStartupRefresh(false);
+ }
+ }, []);
+
const buildKeywordQueryKey = useCallback((appId: string) => {
const normalizedKeywordFilter = keywordFilter.trim().toLowerCase();
const normalizedMinRank = showRankingColumns ? minRank : DEFAULT_MIN_RANK;
@@ -755,12 +780,20 @@ export function App() {
}, [positionHistoryKeyword]);
useEffect(() => {
+ const shouldPoll =
+ !startupRefreshState ||
+ startupRefreshState.status === "idle" ||
+ startupRefreshState.status === "running";
+ if (!shouldPoll) return;
+
let isActive = true;
let intervalId: number | null = null;
const pollStatus = async (): Promise => {
try {
- const data = await apiGet("/api/aso/refresh-status");
+ const data = await apiGet(
+ "/api/aso/refresh-status"
+ );
if (!isActive) return;
setStartupRefreshState(data);
if (
@@ -777,12 +810,9 @@ export function App() {
};
void pollStatus();
- intervalId = window.setInterval(
- () => {
- void pollStatus();
- },
- STARTUP_REFRESH_STATUS_POLL_INTERVAL_SECONDS * 1000
- );
+ intervalId = window.setInterval(() => {
+ void pollStatus();
+ }, STARTUP_REFRESH_STATUS_POLL_INTERVAL_SECONDS * 1000);
return () => {
isActive = false;
@@ -790,7 +820,7 @@ export function App() {
window.clearInterval(intervalId);
}
};
- }, []);
+ }, [startupRefreshState?.status]);
const hasPendingDifficulty = pendingKeywordCount > 0;
@@ -1223,6 +1253,30 @@ export function App() {
});
}, [authStatus, pendingAddContext, submitKeywords]);
+ useEffect(() => {
+ if (authStatus !== "succeeded") return;
+ if (!resumeStartupRefreshAfterAuthRef.current) return;
+ resumeStartupRefreshAfterAuthRef.current = false;
+ void restartStartupRefresh();
+ }, [authStatus, restartStartupRefresh]);
+
+ useEffect(() => {
+ if (!startupRefreshState) return;
+ if (startupRefreshState.status !== "failed") return;
+ if (!startupRefreshState.requiresReauthentication) return;
+
+ const attemptKey =
+ startupRefreshState.finishedAt ??
+ startupRefreshState.startedAt ??
+ startupRefreshState.lastError ??
+ "startup-refresh-auth-required";
+ if (startupRefreshAuthAttemptKeyRef.current === attemptKey) return;
+
+ startupRefreshAuthAttemptKeyRef.current = attemptKey;
+ resumeStartupRefreshAfterAuthRef.current = true;
+ requestStartupRefreshReauthentication();
+ }, [requestStartupRefreshReauthentication, startupRefreshState]);
+
const onAddApp = async (event: React.FormEvent) => {
event.preventDefault();
if (selectedAddCandidateList.length === 0) {
@@ -1660,18 +1714,54 @@ export function App() {
const showError = !showLoading && errorText !== "";
const showSuccess = !showLoading && !showError && successText !== "";
const addButtonLabel = isCompactLayout ? "Add" : "Add Keywords";
- const startupRefreshMessage = useMemo(() => {
+ const startupRefreshBanner = useMemo(() => {
if (!startupRefreshState) return null;
+ const { eligibleKeywordCount, refreshedKeywordCount } =
+ startupRefreshState.counters;
+ const progressLabel =
+ eligibleKeywordCount > 0
+ ? `${refreshedKeywordCount}/${eligibleKeywordCount} keywords`
+ : null;
if (startupRefreshState.status === "running") {
- const { eligibleKeywordCount, refreshedKeywordCount } =
- startupRefreshState.counters;
- if (eligibleKeywordCount > 0) {
- return `Refreshing local data in background (${refreshedKeywordCount}/${eligibleKeywordCount} keywords)...`;
+ return {
+ tone: "muted" as const,
+ action: null,
+ text: progressLabel
+ ? `Refreshing local data in background (${progressLabel})...`
+ : "Refreshing local data in background...",
+ };
+ }
+ if (startupRefreshState.status !== "failed") return null;
+ const progressSuffix = progressLabel ? ` after ${progressLabel}` : "";
+ if (startupRefreshState.requiresReauthentication) {
+ if (
+ activeAuthContext === "startup-refresh" &&
+ (isStartingAuth ||
+ authStatus === "idle" ||
+ authStatus === "in_progress" ||
+ authStatus === "succeeded")
+ ) {
+ return {
+ tone: "muted" as const,
+ action: null,
+ text: `Checking Apple session for background refresh${progressSuffix}...`,
+ };
}
- return "Refreshing local data in background...";
+ return {
+ tone: "error" as const,
+ action: null,
+ text: `Background refresh paused${progressSuffix}. Apple Search Ads session expired.`,
+ };
}
- return null;
- }, [startupRefreshState]);
+ const detail = startupRefreshState.lastError?.trim();
+ return {
+ tone: "error" as const,
+ action: "retry" as const,
+ text: detail
+ ? `Background refresh failed${progressSuffix}. ${detail}`
+ : `Background refresh failed${progressSuffix}.`,
+ };
+ }, [activeAuthContext, authStatus, isStartingAuth, startupRefreshState]);
const hasRankFiltersApplied =
showRankingColumns &&
(minRank !== DEFAULT_MIN_RANK || maxRank !== DEFAULT_MAX_RANK);
@@ -1997,11 +2087,33 @@ export function App() {
-
- {startupRefreshMessage}
-
+
+ {startupRefreshBanner?.text}
+
+ {startupRefreshBanner?.action === "retry" ? (
+
+ ) : null}
+
Date: Sat, 2 May 2026 14:31:32 +0300
Subject: [PATCH 16/55] add analytics for web app
---
docs/website-architecture.md | 5 +
website/package-lock.json | 1068 ++++++++++++++++++++++++++++-
website/package.json | 3 +
website/src/firebase-analytics.ts | 20 +
website/src/pages/index.astro | 6 +
5 files changed, 1090 insertions(+), 12 deletions(-)
create mode 100644 website/src/firebase-analytics.ts
diff --git a/docs/website-architecture.md b/docs/website-architecture.md
index 8c17531..8d05b86 100644
--- a/docs/website-architecture.md
+++ b/docs/website-architecture.md
@@ -30,6 +30,11 @@
- `llms.txt`
- JSON-LD graph for website, software application, and FAQ
+## Analytics Contract
+- Firebase Analytics is enabled for the website to capture default GA4 web events, including page views.
+- Initialization is client-side only from `website/src/pages/index.astro` via `website/src/firebase-analytics.ts`.
+- No custom click/CTA event tracking is implemented; behavior is intentionally limited to default analytics signals.
+
## Deploy Contract
- Netlify is the deployment platform for `website/`.
- Root `netlify.toml` defines:
diff --git a/website/package-lock.json b/website/package-lock.json
index cd428b7..686228d 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -7,6 +7,9 @@
"": {
"name": "aso-cli-website",
"version": "0.1.0",
+ "dependencies": {
+ "firebase": "^12.12.1"
+ },
"devDependencies": {
"astro": "^5.18.1",
"nodemon": "^3.1.10"
@@ -604,6 +607,648 @@
"node": ">=18"
}
},
+ "node_modules/@firebase/ai": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.11.1.tgz",
+ "integrity": "sha512-WGTF81W3WBKJY+c7xqTzO15OGAkCAs8cpADqflAI0skhTZjIkhF0qyf55rq4Ctt6jKygkv99rPfMrjAHTgXaVQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/app-check-interop-types": "0.3.3",
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x",
+ "@firebase/app-types": "0.x"
+ }
+ },
+ "node_modules/@firebase/analytics": {
+ "version": "0.10.21",
+ "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.21.tgz",
+ "integrity": "sha512-j2y2q65BlgLGB5Pwjhv/Jopw2X/TBTzvAtI5z/DSp56U4wBj7LfhBfzbdCtFPges+Wz0g55GdoawXibOH5jGng==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/installations": "0.6.21",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/analytics-compat": {
+ "version": "0.2.27",
+ "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.27.tgz",
+ "integrity": "sha512-ZObpYpAxL6JfgH7GnvlDD0sbzGZ0o4nijV8skatV9ZX49hJtCYbFqaEcPYptT94rgX1KUoKEderC7/fa7hybtw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/analytics": "0.10.21",
+ "@firebase/analytics-types": "0.8.3",
+ "@firebase/component": "0.7.2",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/analytics-types": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz",
+ "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/app": {
+ "version": "0.14.11",
+ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.11.tgz",
+ "integrity": "sha512-yxADFW35LYkP8oSGobGsYIrI42I+GPCvKTNHx4meT9Yq3C950IVz1eANoBk822I9tbKv1wyv9P4Bv1G5TpucFw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "idb": "7.1.1",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/app-check": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.2.tgz",
+ "integrity": "sha512-jcXQVMHAQ5AEKzVD5C7s5fmAYeFOuN6lAJeNTgZK2B9aLnofWaJt8u1A8Idm8gpsBBYSaY3cVyeH5SWMOVPBLQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/app-check-compat": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.2.tgz",
+ "integrity": "sha512-M91NhxqbSkI0ChkJWy69blC+rPr6HEgaeRllddSaU1pQ/7IiegeCQM9pPDIgvWnwnBSzKhUHpe6ro/jhJ+cvzw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/app-check": "0.11.2",
+ "@firebase/app-check-types": "0.5.3",
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/app-check-interop-types": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz",
+ "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/app-check-types": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz",
+ "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/app-compat": {
+ "version": "0.5.11",
+ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.11.tgz",
+ "integrity": "sha512-KaACDjXkK5VLpI01vEs592R7/8s5DjFdIXfKoR385ly1SmK3Tu+jMHCIB4MsiY5jsez6v7VlEX/3rJ90dVkHyA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/app": "0.14.11",
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/app-types": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.4.tgz",
+ "integrity": "sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/logger": "0.5.0"
+ }
+ },
+ "node_modules/@firebase/auth": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.0.tgz",
+ "integrity": "sha512-mKkSLNym3UbnnZ06dAmtqzp5EpPGCANGCZDJbkoR135aoUdKG6Aizwcnp29RzsQpwH0nmy5nay17Sfbsh9oY8A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x",
+ "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@react-native-async-storage/async-storage": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@firebase/auth-compat": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.5.tgz",
+ "integrity": "sha512-IfVsafZ3QiXbsydXTP/XMI0wVYbJLI1rkb8Qqf03/h5FnL+upbbPOb+6Yj3RpcX+Y1iP5Uh18lxTHlXfbiyAow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/auth": "1.13.0",
+ "@firebase/auth-types": "0.13.0",
+ "@firebase/component": "0.7.2",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/auth-interop-types": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz",
+ "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/auth-types": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz",
+ "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@firebase/app-types": "0.x",
+ "@firebase/util": "1.x"
+ }
+ },
+ "node_modules/@firebase/component": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz",
+ "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/data-connect": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.6.0.tgz",
+ "integrity": "sha512-OiugPRcdlhqXF97oR9CjVObILmsWU0dFUS0gXNYEe4bDfpW8pZmQ5GqhIPPtLWbT/0W2lMJJD7VILFMk+xuHPg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/auth-interop-types": "0.2.4",
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/database": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz",
+ "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/app-check-interop-types": "0.3.3",
+ "@firebase/auth-interop-types": "0.2.4",
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "faye-websocket": "0.11.4",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/database-compat": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.3.tgz",
+ "integrity": "sha512-GMyfWjD8mehjg/QpNkY/tl9G/MoeugPeg91n9D0atggxbWuKF/2KhVPHZDH+XmoP0EKYqMWYTtKxBsaBaNKLYQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/database": "1.1.2",
+ "@firebase/database-types": "1.0.19",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/database-types": {
+ "version": "1.0.19",
+ "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.19.tgz",
+ "integrity": "sha512-FqewjUZmV9LqFfuEnmgdcUpiOUz7qwLXxnm/H8BcMFEzQXtd1yyUDm8ex5VRad2nuTE+ahOuCjUAM/cyDncO+g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/app-types": "0.9.4",
+ "@firebase/util": "1.15.0"
+ }
+ },
+ "node_modules/@firebase/firestore": {
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.14.0.tgz",
+ "integrity": "sha512-bZc6YOjRkMBVA16527tgzi6iN9n//xRB3Mmx/R+Gr6UAP/+xrIKOejQIcn1hh+tCzNT8jO0jI+kWox5J4tB/qQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "@firebase/webchannel-wrapper": "1.0.5",
+ "@grpc/grpc-js": "~1.9.0",
+ "@grpc/proto-loader": "^0.7.8",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/firestore-compat": {
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.8.tgz",
+ "integrity": "sha512-WK9NJRpnosGD2nuyjdr7K+Ht7AxRYJlTF62myI4rRA7ibJOosbecvjacR5oirJ7s1BgNS6qzcBw7n4fD3a5w1w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/firestore": "4.14.0",
+ "@firebase/firestore-types": "3.0.3",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/firestore-types": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz",
+ "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@firebase/app-types": "0.x",
+ "@firebase/util": "1.x"
+ }
+ },
+ "node_modules/@firebase/functions": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.3.tgz",
+ "integrity": "sha512-csO7ckK3SSs+NUZW1nms9EK7ckHe/1QOjiP8uAkCYa7ND18s44vjE9g3KxEeIUpyEPqZaX1EhJuFyZjHigAcYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/app-check-interop-types": "0.3.3",
+ "@firebase/auth-interop-types": "0.2.4",
+ "@firebase/component": "0.7.2",
+ "@firebase/messaging-interop-types": "0.2.3",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/functions-compat": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.3.tgz",
+ "integrity": "sha512-BxkEwWgx1of0tKaao/r2VR6WBLk/RAiyztatiONPrPE8gkitFkOnOCxf8i9cUyA5hX5RGt5H30uNn25Q6QNEmQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/functions": "0.13.3",
+ "@firebase/functions-types": "0.6.3",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/functions-types": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz",
+ "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/installations": {
+ "version": "0.6.21",
+ "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.21.tgz",
+ "integrity": "sha512-xGFGTeICJZ5vhrmmDukeczIcFULFXybojML2+QSDFoKj5A7zbGN7KzFGSKNhDkIxpjzsYG9IleJyUebuAcmqWA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/util": "1.15.0",
+ "idb": "7.1.1",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/installations-compat": {
+ "version": "0.2.21",
+ "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.21.tgz",
+ "integrity": "sha512-zahIUkaVKbR8zmTeBHkdfaVl6JGWlhVoSjF7CVH33nFqD3SlPEpEEegn2GNT5iAfsVdtlCyJJ9GW4YKjq+RJKQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/installations": "0.6.21",
+ "@firebase/installations-types": "0.5.3",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/installations-types": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz",
+ "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@firebase/app-types": "0.x"
+ }
+ },
+ "node_modules/@firebase/logger": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz",
+ "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/messaging": {
+ "version": "0.12.25",
+ "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.25.tgz",
+ "integrity": "sha512-7RhDwoDHlOK1/ou0/LeubxmjcngsTjDdrY/ssg2vwAVpUuVAhQzQvuCAOYxcX5wNC1zCgQ54AP1vdngBwbCmOQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/installations": "0.6.21",
+ "@firebase/messaging-interop-types": "0.2.3",
+ "@firebase/util": "1.15.0",
+ "idb": "7.1.1",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/messaging-compat": {
+ "version": "0.2.25",
+ "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.25.tgz",
+ "integrity": "sha512-eoOQqGLtRlseTdiemTN44LlHZpltK5gnhq8XVUuLgtIOG+odtDzrz2UoTpcJWSzaJQVxNLb/x9f39tHdDM4N4w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/messaging": "0.12.25",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/messaging-interop-types": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz",
+ "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/performance": {
+ "version": "0.7.11",
+ "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.11.tgz",
+ "integrity": "sha512-V3uAhrz7IYJuji+OgT3qYTGKxpek/TViXti9OSsUJ4AexZ3jQjYH5Yrn7JvBxk8MGiSLsC872hh+BxQiPZsm7g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/installations": "0.6.21",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0",
+ "web-vitals": "^4.2.4"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/performance-compat": {
+ "version": "0.2.24",
+ "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.24.tgz",
+ "integrity": "sha512-YRlejH8wLt7ThWao+HXoKUHUrZKGYq+otxkPS+8nuE5PeN1cBXX7NAJl9ueuUkBwMIrnKdnDqL/voHXxDAAt3g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/performance": "0.7.11",
+ "@firebase/performance-types": "0.2.3",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/performance-types": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz",
+ "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/remote-config": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.2.tgz",
+ "integrity": "sha512-5EXqOThV4upjK9D38d/qOSVwOqRhemlaOFk9vCkMNNALeIlwr+4pLjtLNo4qoY8etQmU/1q4aIATE9N8PFqg0g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/installations": "0.6.21",
+ "@firebase/logger": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/remote-config-compat": {
+ "version": "0.2.23",
+ "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.23.tgz",
+ "integrity": "sha512-4+KqRRHEUUmKT6tFmnpWATOsaFfmSuBs1jXH8JzVtMLEYqq/WS9IDM92OdefFDSrAA2xGd0WN004z8mKeIIscw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/logger": "0.5.0",
+ "@firebase/remote-config": "0.8.2",
+ "@firebase/remote-config-types": "0.5.0",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/remote-config-types": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz",
+ "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@firebase/storage": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.2.tgz",
+ "integrity": "sha512-o/culaTeJ8GRpKXRJov21rux/n9dRaSOWLebyatFP2sqEdCxQPjVA1H9Z2fzYwQxMIU0JVmC7SPPmU11v7L6vQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app": "0.x"
+ }
+ },
+ "node_modules/@firebase/storage-compat": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.2.tgz",
+ "integrity": "sha512-R+aB38wxCH5zjIO/xu9KznI7fgiPuZAG98uVm1NcidHyyupGgIDLKigGmRGBZMnxibe/m2oxNKoZpfEbUX2aQQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/component": "0.7.2",
+ "@firebase/storage": "0.14.2",
+ "@firebase/storage-types": "0.8.3",
+ "@firebase/util": "1.15.0",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@firebase/app-compat": "0.x"
+ }
+ },
+ "node_modules/@firebase/storage-types": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz",
+ "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@firebase/app-types": "0.x",
+ "@firebase/util": "1.x"
+ }
+ },
+ "node_modules/@firebase/util": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz",
+ "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@firebase/webchannel-wrapper": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz",
+ "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@grpc/grpc-js": {
+ "version": "1.9.15",
+ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz",
+ "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@grpc/proto-loader": "^0.7.8",
+ "@types/node": ">=12.12.47"
+ },
+ "engines": {
+ "node": "^8.13.0 || >=10.10.0"
+ }
+ },
+ "node_modules/@grpc/proto-loader": {
+ "version": "0.7.15",
+ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
+ "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lodash.camelcase": "^4.3.0",
+ "long": "^5.0.0",
+ "protobufjs": "^7.2.5",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1109,6 +1754,70 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
+ "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
@@ -1614,10 +2323,7 @@
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
- "dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2663,6 +3369,93 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2673,6 +3466,24 @@
"node": ">=6"
}
},
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -3094,6 +3905,15 @@
"@esbuild/win32-x64": "0.27.7"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@@ -3128,6 +3948,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/faye-websocket": {
+ "version": "0.11.4",
+ "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
+ "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "websocket-driver": ">=0.5.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3159,6 +3991,42 @@
"node": ">=8"
}
},
+ "node_modules/firebase": {
+ "version": "12.12.1",
+ "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.12.1.tgz",
+ "integrity": "sha512-ee7xA+bTJLfjB9BP/8FQr3EkxmpAAGc1lNc5QkWgTDpUw24HYXFPm7FEWRdLtGnygxIdYpFmepSc5VjkI6NHhw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@firebase/ai": "2.11.1",
+ "@firebase/analytics": "0.10.21",
+ "@firebase/analytics-compat": "0.2.27",
+ "@firebase/app": "0.14.11",
+ "@firebase/app-check": "0.11.2",
+ "@firebase/app-check-compat": "0.4.2",
+ "@firebase/app-compat": "0.5.11",
+ "@firebase/app-types": "0.9.4",
+ "@firebase/auth": "1.13.0",
+ "@firebase/auth-compat": "0.6.5",
+ "@firebase/data-connect": "0.6.0",
+ "@firebase/database": "1.1.2",
+ "@firebase/database-compat": "2.1.3",
+ "@firebase/firestore": "4.14.0",
+ "@firebase/firestore-compat": "0.4.8",
+ "@firebase/functions": "0.13.3",
+ "@firebase/functions-compat": "0.4.3",
+ "@firebase/installations": "0.6.21",
+ "@firebase/installations-compat": "0.2.21",
+ "@firebase/messaging": "0.12.25",
+ "@firebase/messaging-compat": "0.2.25",
+ "@firebase/performance": "0.7.11",
+ "@firebase/performance-compat": "0.2.24",
+ "@firebase/remote-config": "0.8.2",
+ "@firebase/remote-config-compat": "0.2.23",
+ "@firebase/storage": "0.14.2",
+ "@firebase/storage-compat": "0.4.2",
+ "@firebase/util": "1.15.0"
+ }
+ },
"node_modules/flattie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz",
@@ -3207,6 +4075,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-east-asian-width": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
@@ -3480,6 +4357,18 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/http-parser-js": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
+ "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
+ "license": "MIT"
+ },
+ "node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "license": "ISC"
+ },
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -3551,7 +4440,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3651,6 +4539,18 @@
"node": ">=6"
}
},
+ "node_modules/lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -4948,6 +5848,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/protobufjs": {
+ "version": "7.5.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
+ "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.1",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -5154,6 +6078,15 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/retext": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz",
@@ -5264,6 +6197,26 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -5596,9 +6549,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/type-fest": {
"version": "4.41.0",
@@ -5660,10 +6611,7 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
@@ -6002,6 +6950,35 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/web-vitals": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
+ "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/websocket-driver": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
+ "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "http-parser-js": ">=0.5.1",
+ "safe-buffer": ">=5.1.0",
+ "websocket-extensions": ">=0.1.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/websocket-extensions": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
+ "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/which-pm-runs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
@@ -6053,16 +7030,83 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
diff --git a/website/package.json b/website/package.json
index c6d29f1..bad15d3 100644
--- a/website/package.json
+++ b/website/package.json
@@ -13,5 +13,8 @@
"devDependencies": {
"astro": "^5.18.1",
"nodemon": "^3.1.10"
+ },
+ "dependencies": {
+ "firebase": "^12.12.1"
}
}
diff --git a/website/src/firebase-analytics.ts b/website/src/firebase-analytics.ts
new file mode 100644
index 0000000..6340c37
--- /dev/null
+++ b/website/src/firebase-analytics.ts
@@ -0,0 +1,20 @@
+import { getApps, initializeApp } from "firebase/app";
+import { getAnalytics, isSupported } from "firebase/analytics";
+
+const firebaseConfig = {
+ apiKey: "AIzaSyAnUVZsMYNs1Y51mZFeHQ_gmWiNyIPgcp0",
+ authDomain: "aso-cli.firebaseapp.com",
+ projectId: "aso-cli",
+ storageBucket: "aso-cli.firebasestorage.app",
+ messagingSenderId: "353821737266",
+ appId: "1:353821737266:web:7181c1a9fc0070db72d72b",
+ measurementId: "G-RWW1YBRPEQ",
+};
+
+export async function startFirebaseAnalytics() {
+ const supported = await isSupported();
+ if (!supported) return;
+
+ const app = getApps()[0] ?? initializeApp(firebaseConfig);
+ getAnalytics(app);
+}
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index 6404784..5bca8e0 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -321,6 +321,12 @@ const structuredData = {
+
+
diff --git a/website/src/styles/site.css b/website/src/styles/site.css
index a550aef..7c38c27 100644
--- a/website/src/styles/site.css
+++ b/website/src/styles/site.css
@@ -42,10 +42,13 @@ body {
radial-gradient(circle at 8% -14%, #1d5863 0%, transparent 42%),
radial-gradient(circle at 94% 0%, #253660 0%, transparent 36%),
linear-gradient(160deg, #0b1216, var(--bg) 52%, #101820);
+ background-color: var(--bg);
}
body {
position: relative;
+ min-height: 100vh;
+ min-height: 100dvh;
}
a {
@@ -72,7 +75,7 @@ a {
}
.topbar-wrap {
- padding: 1rem 0;
+ padding: calc(1rem + env(safe-area-inset-top, 0px)) 0 1rem;
background: var(--hero-band);
}
@@ -443,6 +446,10 @@ pre code {
line-height: 1.7;
}
+.band-faq {
+ padding-bottom: calc(clamp(2.4rem, 7vw, 5rem) + env(safe-area-inset-bottom, 0px));
+}
+
.faq a {
color: var(--accent-2);
}
From 7579c33ee0c1bd676944b18259b6c94f41529f36 Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Tue, 5 May 2026 22:17:20 +0300
Subject: [PATCH 18/55] Stop startup refresh on auth-required failures
---
.../startup-refresh-manager.test.ts | 56 +++++++++
.../startup-refresh-manager.ts | 13 +-
cli/dashboard-ui/App.tsx | 73 ++++++++---
cli/dashboard-ui/app.auth.test.tsx | 119 +++++++++++++++++-
cli/dashboard-ui/styles.css | 8 +-
docs/aso-error-handling.md | 4 +-
docs/aso-runtime-flows.md | 3 +
7 files changed, 253 insertions(+), 23 deletions(-)
diff --git a/cli/dashboard-server/startup-refresh-manager.test.ts b/cli/dashboard-server/startup-refresh-manager.test.ts
index 0fb0f40..098abde 100644
--- a/cli/dashboard-server/startup-refresh-manager.test.ts
+++ b/cli/dashboard-server/startup-refresh-manager.test.ts
@@ -287,4 +287,60 @@ describe("startup-refresh-manager", () => {
);
expect(attempt).toBe(2);
});
+
+ it("stops startup refresh after first auth-required batch failure", async () => {
+ let attempt = 0;
+
+ const manager = createStartupRefreshManager({
+ country: "US",
+ listKeywords: () => [
+ buildKeyword({
+ keyword: "k1",
+ normalizedKeyword: "k1",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ buildKeyword({
+ keyword: "k2",
+ normalizedKeyword: "k2",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ buildKeyword({
+ keyword: "k3",
+ normalizedKeyword: "k3",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ ],
+ listAppKeywords: () => [
+ buildAssociation({ keyword: "k1", appId: "app-1" }),
+ buildAssociation({ keyword: "k2", appId: "app-1" }),
+ buildAssociation({ keyword: "k3", appId: "app-1" }),
+ ],
+ listAssociatedAppIds: () => new Set(["app-1"]),
+ listOrderRelevantAppIds: () => new Set(["app-1"]),
+ enrichKeywords: async () => {
+ attempt += 1;
+ throw new Error(
+ "Apple Search Ads session expired. Reauthentication is required."
+ );
+ },
+ isForegroundBusy: () => false,
+ isAuthReauthRequiredError: (error) =>
+ error instanceof Error &&
+ error.message.includes("Reauthentication is required"),
+ nowMs: () => Date.parse("2026-03-07T00:00:00.000Z"),
+ sleep: async () => {},
+ keywordBatchSize: 1,
+ });
+
+ manager.start();
+ await waitForManagerToFinish(manager);
+
+ const state = manager.getState();
+ expect(state.status).toBe("failed");
+ expect(state.requiresReauthentication).toBe(true);
+ expect(state.counters.eligibleKeywordCount).toBe(3);
+ expect(state.counters.refreshedKeywordCount).toBe(0);
+ expect(state.counters.failedKeywordCount).toBe(1);
+ expect(attempt).toBe(1);
+ });
});
diff --git a/cli/dashboard-server/startup-refresh-manager.ts b/cli/dashboard-server/startup-refresh-manager.ts
index b97d03b..b65ca23 100644
--- a/cli/dashboard-server/startup-refresh-manager.ts
+++ b/cli/dashboard-server/startup-refresh-manager.ts
@@ -171,10 +171,13 @@ export function createStartupRefreshManager(
let runPromise: Promise | null = null;
const setFailure = (error: unknown, metadata: Record) => {
+ const isAuthReauthRequired =
+ deps.isAuthReauthRequiredError?.(error) === true;
if (!state.lastError) {
state.lastError = errorToMessage(error);
- state.requiresReauthentication =
- deps.isAuthReauthRequiredError?.(error) === true;
+ }
+ if (isAuthReauthRequired) {
+ state.requiresReauthentication = true;
}
deps.reportError?.(error, metadata);
};
@@ -201,12 +204,18 @@ export function createStartupRefreshManager(
}, sleep, (error) => deps.isAuthReauthRequiredError?.(error) !== true);
state.counters.refreshedKeywordCount += batch.length;
} catch (error) {
+ const isAuthReauthRequired =
+ deps.isAuthReauthRequiredError?.(error) === true;
state.counters.failedKeywordCount += batch.length;
setFailure(error, {
phase: "startup-keyword-refresh",
batchSize: batch.length,
keywordPreview: batch.slice(0, 5).map((item) => item.keyword),
+ isAuthReauthRequired,
});
+ if (isAuthReauthRequired) {
+ break;
+ }
}
}
};
diff --git a/cli/dashboard-ui/App.tsx b/cli/dashboard-ui/App.tsx
index d1c8347..f98e22e 100644
--- a/cli/dashboard-ui/App.tsx
+++ b/cli/dashboard-ui/App.tsx
@@ -458,6 +458,8 @@ export function App() {
() => apps.some((app) => Boolean(app.lastKeywordAddedAt)),
[apps]
);
+ const isKeywordMutationBlockedByStartupReauth =
+ startupRefreshState?.requiresReauthentication === true;
const loadApps = useCallback(async (): Promise => {
const list = await apiGet(`/api/apps`);
@@ -1156,6 +1158,10 @@ export function App() {
const submitKeywords = useCallback(
async (kws: string[]): Promise => {
+ if (isKeywordMutationBlockedByStartupReauth) {
+ setErrorText("Finish Apple reauthentication before adding or retrying keywords.");
+ return false;
+ }
try {
setIsAddingKeywords(true);
setErrorText("");
@@ -1193,6 +1199,7 @@ export function App() {
loadKeywords,
openAuthModalForPendingAdd,
openSetupModalForPrimaryAppAccessError,
+ isKeywordMutationBlockedByStartupReauth,
]
);
@@ -1224,6 +1231,10 @@ export function App() {
const onRetryFailedKeywords = useCallback(async () => {
if (failedKeywordCount <= 0) return;
+ if (isKeywordMutationBlockedByStartupReauth) {
+ setErrorText("Finish Apple reauthentication before adding or retrying keywords.");
+ return;
+ }
try {
setIsRetryingFailedKeywords(true);
setErrorText("");
@@ -1261,6 +1272,7 @@ export function App() {
loadKeywords,
openSetupModalForPrimaryAppAccessError,
selectedAppId,
+ isKeywordMutationBlockedByStartupReauth,
]);
useEffect(() => {
@@ -1724,7 +1736,28 @@ export function App() {
const showError = !showLoading && errorText !== "";
const showSuccess = !showLoading && !showError && successText !== "";
const addButtonLabel = isCompactLayout ? "Add" : "Add Keywords";
+ const isStartupRefreshAuthRecoveryActive =
+ isKeywordMutationBlockedByStartupReauth &&
+ activeAuthContext === "startup-refresh" &&
+ (isStartingAuth ||
+ authStatus === "idle" ||
+ authStatus === "in_progress" ||
+ authStatus === "succeeded");
+ const keywordMutationStatusText = isStartupRefreshAuthRecoveryActive
+ ? "Checking Apple session..."
+ : authCheckLoadingText !== ""
+ ? "Checking Apple session..."
+ : isKeywordMutationBlockedByStartupReauth
+ ? "Apple reauthentication required."
+ : "";
const startupRefreshBanner = useMemo(() => {
+ if (keywordMutationStatusText) {
+ return {
+ tone: isKeywordMutationBlockedByStartupReauth ? ("error" as const) : ("muted" as const),
+ action: null,
+ text: keywordMutationStatusText,
+ };
+ }
if (!startupRefreshState) return null;
const { eligibleKeywordCount, refreshedKeywordCount } =
startupRefreshState.counters;
@@ -1744,23 +1777,10 @@ export function App() {
if (startupRefreshState.status !== "failed") return null;
const progressSuffix = progressLabel ? ` after ${progressLabel}` : "";
if (startupRefreshState.requiresReauthentication) {
- if (
- activeAuthContext === "startup-refresh" &&
- (isStartingAuth ||
- authStatus === "idle" ||
- authStatus === "in_progress" ||
- authStatus === "succeeded")
- ) {
- return {
- tone: "muted" as const,
- action: null,
- text: `Checking Apple session for background refresh${progressSuffix}...`,
- };
- }
return {
tone: "error" as const,
action: null,
- text: `Background refresh paused${progressSuffix}. Apple Search Ads session expired.`,
+ text: "Apple reauthentication required.",
};
}
const detail = startupRefreshState.lastError?.trim();
@@ -1771,7 +1791,14 @@ export function App() {
? `Background refresh failed${progressSuffix}. ${detail}`
: `Background refresh failed${progressSuffix}.`,
};
- }, [activeAuthContext, authStatus, isStartingAuth, startupRefreshState]);
+ }, [
+ authCheckLoadingText,
+ authStatus,
+ isKeywordMutationBlockedByStartupReauth,
+ isStartingAuth,
+ keywordMutationStatusText,
+ startupRefreshState,
+ ]);
const hasRankFiltersApplied =
showRankingColumns &&
(minRank !== DEFAULT_MIN_RANK || maxRank !== DEFAULT_MAX_RANK);
@@ -2065,7 +2092,15 @@ export function App() {
onChange={(e) => setAddInput(e.target.value)}
/>
-
+
{addButtonLabel}
@@ -2075,7 +2110,11 @@ export function App() {
className="retry-failed-button"
type="button"
variant="ghost"
- disabled={isRetryingFailedKeywords || isColdStart}
+ disabled={
+ isRetryingFailedKeywords ||
+ isColdStart ||
+ isKeywordMutationBlockedByStartupReauth
+ }
onClick={() => {
void onRetryFailedKeywords();
}}
diff --git a/cli/dashboard-ui/app.auth.test.tsx b/cli/dashboard-ui/app.auth.test.tsx
index f2a5656..8a60ed8 100644
--- a/cli/dashboard-ui/app.auth.test.tsx
+++ b/cli/dashboard-ui/app.auth.test.tsx
@@ -1010,7 +1010,7 @@ describe("dashboard auth modal UI flow", () => {
expect(
await screen.findByText(
- "Checking Apple session for background refresh after 0/1086 keywords..."
+ "Checking Apple session..."
)
).toBeInTheDocument();
@@ -1023,6 +1023,123 @@ describe("dashboard auth modal UI flow", () => {
).toBeInTheDocument();
});
+ it("disables add/retry keyword mutations while startup refresh reauthentication is required", async () => {
+ let keywordPostCount = 0;
+
+ const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+
+ if (method === "GET" && url === "/api/apps") {
+ return jsonResponse({ status: 200, body: { success: true, data: [] } });
+ }
+ if (method === "GET" && url.startsWith("/api/aso/keywords?")) {
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ ...emptyKeywordPagedPayload(),
+ failedCount: 2,
+ },
+ },
+ });
+ }
+ if (method === "GET" && url === "/api/aso/refresh-status") {
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ status: "failed",
+ startedAt: "2026-03-07T00:00:00.000Z",
+ finishedAt: "2026-03-07T00:00:10.000Z",
+ lastError:
+ "Apple Search Ads session expired. Reauthentication is required.",
+ requiresReauthentication: true,
+ counters: {
+ eligibleKeywordCount: 1086,
+ refreshedKeywordCount: 0,
+ failedKeywordCount: 1086,
+ },
+ },
+ },
+ });
+ }
+ if (method === "POST" && url === "/api/aso/auth/start") {
+ return jsonResponse({
+ status: 202,
+ body: {
+ success: true,
+ data: {
+ status: "in_progress",
+ updatedAt: "2026-03-07T00:00:11.000Z",
+ lastError: null,
+ requiresTerminalAction: false,
+ canPrompt: true,
+ },
+ },
+ });
+ }
+ if (method === "GET" && url === "/api/aso/auth/status") {
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ status: "in_progress",
+ updatedAt: "2026-03-07T00:00:12.000Z",
+ lastError: null,
+ requiresTerminalAction: false,
+ canPrompt: true,
+ },
+ },
+ });
+ }
+ if (method === "POST" && url === "/api/aso/keywords") {
+ keywordPostCount += 1;
+ return jsonResponse({
+ status: 201,
+ body: {
+ success: true,
+ data: {
+ cachedCount: 0,
+ pendingCount: 0,
+ failedCount: 0,
+ },
+ },
+ });
+ }
+
+ throw new Error(`Unhandled fetch: ${method} ${url}`);
+ });
+ global.fetch = fetchMock as typeof fetch;
+
+ render();
+
+ expect(
+ await screen.findByText(
+ "Checking Apple session..."
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Add Keywords" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Retry Failed (2)" })).toBeDisabled();
+
+ const input = screen.getByPlaceholderText("Add keywords (comma-separated)");
+ fireEvent.change(input, { target: { value: "term" } });
+ const addForm = document.querySelector("#add-form");
+ if (!(addForm instanceof HTMLFormElement)) {
+ throw new Error("Expected add keywords form to be present.");
+ }
+ fireEvent.submit(addForm);
+ expect(keywordPostCount).toBe(0);
+ expect(
+ await screen.findByText(
+ "Finish Apple reauthentication before adding or retrying keywords."
+ )
+ ).toBeInTheDocument();
+ });
+
it("restarts startup refresh reauthentication after a later auth-required failure", async () => {
let refreshStatusCount = 0;
let refreshStartCount = 0;
diff --git a/cli/dashboard-ui/styles.css b/cli/dashboard-ui/styles.css
index 1f9afdc..791b8b3 100644
--- a/cli/dashboard-ui/styles.css
+++ b/cli/dashboard-ui/styles.css
@@ -513,7 +513,8 @@ body {
.startup-refresh-status {
margin: 0 4px 0 0;
- max-width: min(52vw, 420px);
+ flex: 1 1 auto;
+ max-width: none;
display: none;
align-items: center;
gap: 8px;
@@ -526,6 +527,8 @@ body {
.startup-refresh-status-copy {
margin: 0;
+ flex: 1 1 auto;
+ min-width: 0;
font-size: 0.78rem;
color: var(--muted-foreground);
white-space: nowrap;
@@ -2080,7 +2083,8 @@ body::before {
.startup-refresh-status {
margin-right: auto;
- max-width: min(56vw, 420px);
+ flex: 1 1 auto;
+ max-width: none;
}
.startup-refresh-status-copy {
diff --git a/docs/aso-error-handling.md b/docs/aso-error-handling.md
index 4942153..379a682 100644
--- a/docs/aso-error-handling.md
+++ b/docs/aso-error-handling.md
@@ -29,7 +29,7 @@ Define failure boundaries, retry rules, and recovery behavior across CLI, dashbo
- Popularity fetch retries transient responses (`429`, `5xx`, `KWS_NO_ORG_CONTENT_PROVIDERS`) and transient network errors.
- App Store web fetches retry `429`, `5xx`, and transient network errors with jittered exponential backoff.
- Enrichment applies one bounded retry/backoff cycle for competitive keywords when top-5 difficulty docs are incomplete (`reasonCode=INSUFFICIENT_DOCS` when still unresolved).
-- Startup refresh manager retries each unit once and records failures without crashing runtime.
+- Startup refresh manager retries each unit once for non-auth failures; auth-required failures are terminal for the current run and stop remaining batches.
- Startup refresh auth failures are exposed as structured refresh-status state so the dashboard can prompt for reauthentication instead of silently stopping.
- Startup refresh auth failures reuse the same dashboard reauthentication UX as add-keyword: one automatic auth-start attempt, then shared browser prompt / retry handling if user input is needed.
- `keywordPipelineService` isolates terminal failures per keyword and stores normalized failure metadata in `aso_keyword_failures` via `keywordWriteRepository` (single write owner).
@@ -38,6 +38,8 @@ Define failure boundaries, retry rules, and recovery behavior across CLI, dashbo
- Dashboard add-keyword:
- If auth is invalid in stage 1, return `AUTH_REQUIRED` (no interactive prompt in request path).
- If the configured Primary App ID is inaccessible for the current Apple Ads account, return `PRIMARY_APP_ID_RECONFIGURE_REQUIRED` so the dashboard can reopen the shared Primary App ID setup flow.
+- Dashboard startup-refresh auth recovery:
+ - While refresh status reports `requiresReauthentication=true`, keyword mutations (`Add Keywords`, `Retry Failed`) are disabled until reauthentication succeeds.
- If stage-2 enrichment fails, stage-1 writes remain; caller can retry later.
- Stage-2 enrichment writes are per-keyword progressive: successful keywords are visible in cache/UI polling as soon as that keyword finishes, while other keywords continue running.
- For `appCount >= 5`, enrichment does not persist fallback `difficultyScore=1` when top-5 docs are incomplete; it records a retryable enrichment failure instead.
diff --git a/docs/aso-runtime-flows.md b/docs/aso-runtime-flows.md
index 535cf59..1efba7d 100644
--- a/docs/aso-runtime-flows.md
+++ b/docs/aso-runtime-flows.md
@@ -136,9 +136,12 @@ Runtime flow contracts across CLI commands, local dashboard API, and ASO service
- difficulty has never been computed
- order TTL is stale (owned-associated keywords only; research-only keywords do not use order staleness as a refresh trigger)
3. Run the same keyword pipeline used by CLI fetch in non-interactive mode, in batches while pausing for foreground mutations.
+ - Non-auth batch failures get one bounded retry.
+ - Auth-required failures skip retry and stop remaining startup batches for that run.
4. Publish refresh status via `GET /api/aso/refresh-status`, including whether the failure requires Search Ads reauthentication.
5. If auth is required, the dashboard auto-starts the same reauthentication flow used by add-keyword once; silent session reuse stays invisible, while browser prompt steps/failure states surface through the shared auth modal.
6. Allow explicit restart via `POST /api/aso/refresh/start`; the UI uses this after reauthentication or manual retry.
+7. While startup refresh is paused on `requiresReauthentication=true`, dashboard keyword mutations (`Add Keywords`, `Retry Failed`) stay disabled until reauthentication succeeds.
## Flow E: App Doc Hydration
- `GET /api/apps` (owned app list):
From 01622da2e93e972b7f78fdcbc28dd3d57edcfa13 Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Wed, 6 May 2026 19:06:03 +0300
Subject: [PATCH 19/55] Clarify startup reauth auto-recovery status
---
cli/dashboard-ui/App.tsx | 27 +++--
cli/dashboard-ui/app.auth.test.tsx | 152 ++++++++++++++++++++++++++++-
2 files changed, 168 insertions(+), 11 deletions(-)
diff --git a/cli/dashboard-ui/App.tsx b/cli/dashboard-ui/App.tsx
index f98e22e..f8bb9eb 100644
--- a/cli/dashboard-ui/App.tsx
+++ b/cli/dashboard-ui/App.tsx
@@ -1736,15 +1736,22 @@ export function App() {
const showError = !showLoading && errorText !== "";
const showSuccess = !showLoading && !showError && successText !== "";
const addButtonLabel = isCompactLayout ? "Add" : "Add Keywords";
- const isStartupRefreshAuthRecoveryActive =
+ const isStartupRefreshAuthRecoveryAttemptActive =
isKeywordMutationBlockedByStartupReauth &&
activeAuthContext === "startup-refresh" &&
(isStartingAuth ||
authStatus === "idle" ||
- authStatus === "in_progress" ||
- authStatus === "succeeded");
- const keywordMutationStatusText = isStartupRefreshAuthRecoveryActive
- ? "Checking Apple session..."
+ authStatus === "in_progress");
+ const isStartupRefreshResumeInProgress =
+ isKeywordMutationBlockedByStartupReauth &&
+ (isRestartingStartupRefresh || authStatus === "succeeded");
+ const isStartupRefreshAutoRecoveryActive =
+ isStartupRefreshAuthRecoveryAttemptActive ||
+ isStartupRefreshResumeInProgress;
+ const keywordMutationStatusText = isStartupRefreshAuthRecoveryAttemptActive
+ ? "Reauthenticating with Apple..."
+ : isStartupRefreshResumeInProgress
+ ? "Resuming background refresh..."
: authCheckLoadingText !== ""
? "Checking Apple session..."
: isKeywordMutationBlockedByStartupReauth
@@ -1753,7 +1760,11 @@ export function App() {
const startupRefreshBanner = useMemo(() => {
if (keywordMutationStatusText) {
return {
- tone: isKeywordMutationBlockedByStartupReauth ? ("error" as const) : ("muted" as const),
+ tone:
+ isKeywordMutationBlockedByStartupReauth &&
+ !isStartupRefreshAutoRecoveryActive
+ ? ("error" as const)
+ : ("muted" as const),
action: null,
text: keywordMutationStatusText,
};
@@ -1792,10 +1803,8 @@ export function App() {
: `Background refresh failed${progressSuffix}.`,
};
}, [
- authCheckLoadingText,
- authStatus,
isKeywordMutationBlockedByStartupReauth,
- isStartingAuth,
+ isStartupRefreshAutoRecoveryActive,
keywordMutationStatusText,
startupRefreshState,
]);
diff --git a/cli/dashboard-ui/app.auth.test.tsx b/cli/dashboard-ui/app.auth.test.tsx
index 8a60ed8..949fa5e 100644
--- a/cli/dashboard-ui/app.auth.test.tsx
+++ b/cli/dashboard-ui/app.auth.test.tsx
@@ -1010,7 +1010,7 @@ describe("dashboard auth modal UI flow", () => {
expect(
await screen.findByText(
- "Checking Apple session..."
+ "Reauthenticating with Apple..."
)
).toBeInTheDocument();
@@ -1119,7 +1119,7 @@ describe("dashboard auth modal UI flow", () => {
expect(
await screen.findByText(
- "Checking Apple session..."
+ "Reauthenticating with Apple..."
)
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Keywords" })).toBeDisabled();
@@ -1140,6 +1140,154 @@ describe("dashboard auth modal UI flow", () => {
).toBeInTheDocument();
});
+ it("shows auto-resume status while startup refresh restart is in flight after auth succeeds", async () => {
+ let refreshStatusCount = 0;
+ let refreshStartCount = 0;
+ let authStartCount = 0;
+ let resolveRefreshStart: ((value: Response) => void) | null = null;
+ const refreshStartPromise = new Promise((resolve) => {
+ resolveRefreshStart = resolve;
+ });
+
+ const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+
+ if (method === "GET" && url === "/api/apps") {
+ return jsonResponse({ status: 200, body: { success: true, data: [] } });
+ }
+ if (method === "GET" && url.startsWith("/api/aso/keywords?")) {
+ return jsonResponse({
+ status: 200,
+ body: { success: true, data: emptyKeywordPagedPayload() },
+ });
+ }
+ if (method === "GET" && url === "/api/aso/refresh-status") {
+ refreshStatusCount += 1;
+ if (refreshStatusCount === 1) {
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ status: "failed",
+ startedAt: "2026-03-07T00:00:00.000Z",
+ finishedAt: "2026-03-07T00:00:10.000Z",
+ lastError:
+ "Apple Search Ads session expired. Reauthentication is required.",
+ requiresReauthentication: true,
+ counters: {
+ eligibleKeywordCount: 1086,
+ refreshedKeywordCount: 0,
+ failedKeywordCount: 1086,
+ },
+ },
+ },
+ });
+ }
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ status: "running",
+ startedAt: "2026-03-07T00:00:20.000Z",
+ finishedAt: null,
+ lastError: null,
+ requiresReauthentication: false,
+ counters: {
+ eligibleKeywordCount: 1086,
+ refreshedKeywordCount: 0,
+ failedKeywordCount: 0,
+ },
+ },
+ },
+ });
+ }
+ if (method === "POST" && url === "/api/aso/auth/start") {
+ authStartCount += 1;
+ return jsonResponse({
+ status: 202,
+ body: {
+ success: true,
+ data: {
+ status: "in_progress",
+ updatedAt: "2026-03-07T00:00:11.000Z",
+ lastError: null,
+ requiresTerminalAction: false,
+ canPrompt: true,
+ },
+ },
+ });
+ }
+ if (method === "GET" && url === "/api/aso/auth/status") {
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ status: "succeeded",
+ updatedAt: "2026-03-07T00:00:12.000Z",
+ lastError: null,
+ requiresTerminalAction: false,
+ canPrompt: true,
+ },
+ },
+ });
+ }
+ if (method === "POST" && url === "/api/aso/refresh/start") {
+ refreshStartCount += 1;
+ return refreshStartPromise;
+ }
+
+ throw new Error(`Unhandled fetch: ${method} ${url}`);
+ });
+ global.fetch = fetchMock as typeof fetch;
+
+ render();
+
+ await waitFor(() => expect(authStartCount).toBe(1));
+ await waitFor(() => expect(refreshStartCount).toBe(1));
+ expect(
+ await screen.findByText("Resuming background refresh...")
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText("Apple reauthentication required.")
+ ).not.toBeInTheDocument();
+
+ const finishRefreshStart =
+ resolveRefreshStart as ((value: Response) => void) | null;
+ if (!finishRefreshStart) {
+ throw new Error("Expected startup refresh restart request to be pending.");
+ }
+ finishRefreshStart(
+ jsonResponse({
+ status: 202,
+ body: {
+ success: true,
+ data: {
+ status: "running",
+ startedAt: "2026-03-07T00:00:20.000Z",
+ finishedAt: null,
+ lastError: null,
+ requiresReauthentication: false,
+ counters: {
+ eligibleKeywordCount: 1086,
+ refreshedKeywordCount: 0,
+ failedKeywordCount: 0,
+ },
+ },
+ },
+ }) as Response
+ );
+
+ expect(
+ await screen.findByText(
+ "Refreshing local data in background (0/1086 keywords)..."
+ )
+ ).toBeInTheDocument();
+ });
+
it("restarts startup refresh reauthentication after a later auth-required failure", async () => {
let refreshStatusCount = 0;
let refreshStartCount = 0;
From 5b772f2a028637d7128c21c19e7a06ff5b6d30cf Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Thu, 7 May 2026 12:16:14 +0300
Subject: [PATCH 20/55] Shorten startup refresh errors and constrain banner
layout
---
cli/dashboard-ui/App.tsx | 5 +--
cli/dashboard-ui/app.auth.test.tsx | 54 ++++++++++++++++++++++++++++++
cli/dashboard-ui/styles.css | 14 ++++++++
3 files changed, 69 insertions(+), 4 deletions(-)
diff --git a/cli/dashboard-ui/App.tsx b/cli/dashboard-ui/App.tsx
index f8bb9eb..ad83a5b 100644
--- a/cli/dashboard-ui/App.tsx
+++ b/cli/dashboard-ui/App.tsx
@@ -1794,13 +1794,10 @@ export function App() {
text: "Apple reauthentication required.",
};
}
- const detail = startupRefreshState.lastError?.trim();
return {
tone: "error" as const,
action: "retry" as const,
- text: detail
- ? `Background refresh failed${progressSuffix}. ${detail}`
- : `Background refresh failed${progressSuffix}.`,
+ text: `Background refresh failed${progressSuffix}.`,
};
}, [
isKeywordMutationBlockedByStartupReauth,
diff --git a/cli/dashboard-ui/app.auth.test.tsx b/cli/dashboard-ui/app.auth.test.tsx
index 949fa5e..8ea3d4e 100644
--- a/cli/dashboard-ui/app.auth.test.tsx
+++ b/cli/dashboard-ui/app.auth.test.tsx
@@ -1288,6 +1288,60 @@ describe("dashboard auth modal UI flow", () => {
).toBeInTheDocument();
});
+ it("shows concise startup refresh failure text without backend reason details", async () => {
+ const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+
+ if (method === "GET" && url === "/api/apps") {
+ return jsonResponse({ status: 200, body: { success: true, data: [] } });
+ }
+ if (method === "GET" && url.startsWith("/api/aso/keywords?")) {
+ return jsonResponse({
+ status: 200,
+ body: { success: true, data: emptyKeywordPagedPayload() },
+ });
+ }
+ if (method === "GET" && url === "/api/aso/refresh-status") {
+ return jsonResponse({
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ status: "failed",
+ startedAt: "2026-03-07T00:00:00.000Z",
+ finishedAt: "2026-03-07T00:00:10.000Z",
+ lastError:
+ "All keywords failed (17): up:INSUFFICIENT_DOCS(503), videoleap:INSUFFICIENT_DOCS(503)",
+ requiresReauthentication: false,
+ counters: {
+ eligibleKeywordCount: 517,
+ refreshedKeywordCount: 500,
+ failedKeywordCount: 17,
+ },
+ },
+ },
+ });
+ }
+
+ throw new Error(`Unhandled fetch: ${method} ${url}`);
+ });
+ global.fetch = fetchMock as typeof fetch;
+
+ render();
+
+ expect(
+ await screen.findByText("Background refresh failed after 500/517 keywords.")
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/INSUFFICIENT_DOCS/)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/All keywords failed \(17\)/)
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Retry Refresh" })
+ ).toBeInTheDocument();
+ });
+
it("restarts startup refresh reauthentication after a later auth-required failure", async () => {
let refreshStatusCount = 0;
let refreshStartCount = 0;
diff --git a/cli/dashboard-ui/styles.css b/cli/dashboard-ui/styles.css
index 791b8b3..a73bd4d 100644
--- a/cli/dashboard-ui/styles.css
+++ b/cli/dashboard-ui/styles.css
@@ -509,6 +509,9 @@ body {
gap: 10px;
padding: 0;
min-height: 34px;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
}
.startup-refresh-status {
@@ -519,6 +522,7 @@ body {
align-items: center;
gap: 8px;
min-width: 0;
+ overflow: hidden;
}
.startup-refresh-status.visible {
@@ -529,6 +533,7 @@ body {
margin: 0;
flex: 1 1 auto;
min-width: 0;
+ max-width: 100%;
font-size: 0.78rem;
color: var(--muted-foreground);
white-space: nowrap;
@@ -2079,17 +2084,26 @@ body::before {
padding: 0;
justify-content: flex-end;
gap: 8px;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
}
.startup-refresh-status {
margin-right: auto;
flex: 1 1 auto;
max-width: none;
+ min-width: 0;
+ overflow: hidden;
}
.startup-refresh-status-copy {
font-size: 11px;
color: #7d7d7d;
+ max-width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.table-card {
From 97c7b1dc3d40d8651d2551bf36a9c6b7566b6506 Mon Sep 17 00:00:00 2001
From: Semih Cihan
Date: Thu, 7 May 2026 17:05:49 +0300
Subject: [PATCH 21/55] Centralize Apple retry and app doc normalization
helpers
---
.../startup-refresh-manager.test.ts | 93 +++++++-
.../startup-refresh-manager.ts | 33 ++-
.../services/aso-app-doc-service.test.ts | 64 ++++++
.../cache-api/services/aso-app-doc-service.ts | 186 ++++++++++++++--
.../services/aso-app-doc-utils.test.ts | 23 ++
.../cache-api/services/aso-app-doc-utils.ts | 12 +
.../services/aso-apple-client.test.ts | 10 +-
.../cache-api/services/aso-apple-client.ts | 91 ++------
.../services/aso-enrichment-service.test.ts | 206 +++++++++++++++++-
.../services/aso-enrichment-service.ts | 116 +++++++---
.../aso-apple-popularity-client.test.ts | 47 ++--
.../keywords/aso-apple-popularity-client.ts | 100 +++------
.../keywords/aso-popularity-service.test.ts | 20 +-
.../keywords/aso-popularity-service.ts | 12 +-
cli/shared/apple-upstream-error.ts | 14 +-
cli/shared/aso-apple-http.ts | 2 +
cli/shared/aso-env.ts | 2 +-
cli/shared/aso-retry-delay.test.ts | 63 ++++++
cli/shared/aso-retry-delay.ts | 65 ++++++
cli/shared/aso-transient-error.ts | 58 +++++
docs/aso-error-handling.md | 9 +-
docs/aso-keyword-fetch-design.md | 1 +
docs/aso-runtime-flows.md | 7 +-
23 files changed, 991 insertions(+), 243 deletions(-)
create mode 100644 cli/services/cache-api/services/aso-app-doc-utils.test.ts
create mode 100644 cli/services/cache-api/services/aso-app-doc-utils.ts
create mode 100644 cli/shared/aso-apple-http.ts
create mode 100644 cli/shared/aso-retry-delay.test.ts
create mode 100644 cli/shared/aso-retry-delay.ts
create mode 100644 cli/shared/aso-transient-error.ts
diff --git a/cli/dashboard-server/startup-refresh-manager.test.ts b/cli/dashboard-server/startup-refresh-manager.test.ts
index 098abde..993537f 100644
--- a/cli/dashboard-server/startup-refresh-manager.test.ts
+++ b/cli/dashboard-server/startup-refresh-manager.test.ts
@@ -169,7 +169,9 @@ describe("startup-refresh-manager", () => {
enrichKeywords: async (_country, items) => {
enrichCalls.push(items);
if (enrichCalls.length === 1) {
- throw new Error("first batch failed");
+ throw Object.assign(new Error("transient network failure"), {
+ code: "ECONNRESET",
+ });
}
},
isForegroundBusy: () => false,
@@ -211,7 +213,9 @@ describe("startup-refresh-manager", () => {
listAssociatedAppIds: () => new Set(["app-1"]),
listOrderRelevantAppIds: () => new Set(["app-1"]),
enrichKeywords: async () => {
- throw new Error("always fails");
+ throw Object.assign(new Error("always fails transiently"), {
+ code: "ETIMEDOUT",
+ });
},
isForegroundBusy: () => false,
reportError: (error) => {
@@ -233,6 +237,41 @@ describe("startup-refresh-manager", () => {
expect(errors).toHaveLength(1);
});
+ it("does not retry deterministic non-transient batch failures", async () => {
+ let enrichCalls = 0;
+
+ const manager = createStartupRefreshManager({
+ country: "US",
+ listKeywords: () => [
+ buildKeyword({
+ keyword: "k1",
+ normalizedKeyword: "k1",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ ],
+ listAppKeywords: () => [buildAssociation({ keyword: "k1", appId: "app-1" })],
+ listAssociatedAppIds: () => new Set(["app-1"]),
+ listOrderRelevantAppIds: () => new Set(["app-1"]),
+ enrichKeywords: async () => {
+ enrichCalls += 1;
+ throw new Error("deterministic failure");
+ },
+ isForegroundBusy: () => false,
+ nowMs: () => Date.parse("2026-03-07T00:00:00.000Z"),
+ sleep: async () => {},
+ keywordBatchSize: 25,
+ });
+
+ manager.start();
+ await waitForManagerToFinish(manager);
+
+ const state = manager.getState();
+ expect(state.status).toBe("failed");
+ expect(state.counters.refreshedKeywordCount).toBe(0);
+ expect(state.counters.failedKeywordCount).toBe(1);
+ expect(enrichCalls).toBe(1);
+ });
+
it("marks auth-required failures and allows restart after failure", async () => {
let attempt = 0;
@@ -343,4 +382,54 @@ describe("startup-refresh-manager", () => {
expect(state.counters.failedKeywordCount).toBe(1);
expect(attempt).toBe(1);
});
+
+ it("does not retry exhausted all-keywords-failed batches", async () => {
+ let enrichCalls = 0;
+
+ const manager = createStartupRefreshManager({
+ country: "US",
+ listKeywords: () => [
+ buildKeyword({
+ keyword: "k1",
+ normalizedKeyword: "k1",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ buildKeyword({
+ keyword: "k2",
+ normalizedKeyword: "k2",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ buildKeyword({
+ keyword: "k3",
+ normalizedKeyword: "k3",
+ orderExpiresAt: "2026-03-06T00:00:00.000Z",
+ }),
+ ],
+ listAppKeywords: () => [
+ buildAssociation({ keyword: "k1", appId: "app-1" }),
+ buildAssociation({ keyword: "k2", appId: "app-1" }),
+ buildAssociation({ keyword: "k3", appId: "app-1" }),
+ ],
+ listAssociatedAppIds: () => new Set(["app-1"]),
+ listOrderRelevantAppIds: () => new Set(["app-1"]),
+ enrichKeywords: async () => {
+ enrichCalls += 1;
+ throw new Error("All keywords failed (1): k1:INSUFFICIENT_DOCS(503)");
+ },
+ isForegroundBusy: () => false,
+ nowMs: () => Date.parse("2026-03-07T00:00:00.000Z"),
+ sleep: async () => {},
+ keywordBatchSize: 1,
+ });
+
+ manager.start();
+ await waitForManagerToFinish(manager);
+
+ const state = manager.getState();
+ expect(state.status).toBe("failed");
+ expect(state.counters.eligibleKeywordCount).toBe(3);
+ expect(state.counters.refreshedKeywordCount).toBe(0);
+ expect(state.counters.failedKeywordCount).toBe(3);
+ expect(enrichCalls).toBe(3);
+ });
});
diff --git a/cli/dashboard-server/startup-refresh-manager.ts b/cli/dashboard-server/startup-refresh-manager.ts
index b65ca23..18117b4 100644
--- a/cli/dashboard-server/startup-refresh-manager.ts
+++ b/cli/dashboard-server/startup-refresh-manager.ts
@@ -1,5 +1,9 @@
import type { StoredAppKeyword, StoredAsoKeyword } from "../db/types";
import { normalizeKeyword } from "../shared/aso-keyword-utils";
+import {
+ isKnownTransientStatusCode,
+ isTransientTransportFailure,
+} from "../shared/aso-transient-error";
import {
isCompleteStoredAsoKeyword,
isStoredKeywordOrderFresh,
@@ -139,6 +143,25 @@ function errorToMessage(error: unknown): string {
return raw.trim() || "Background refresh failed.";
}
+function isExhaustedBatchFailure(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+ const message = error.message.trim();
+ if (!message) return false;
+ return message.startsWith("All keywords failed (");
+}
+
+function isTransientStartupFailure(error: unknown): boolean {
+ if (!error || typeof error !== "object") return false;
+ const statusCode = (error as { statusCode?: unknown }).statusCode;
+ if (typeof statusCode === "number" && isKnownTransientStatusCode(statusCode)) {
+ return true;
+ }
+ return isTransientTransportFailure({
+ code: String((error as { code?: unknown }).code ?? ""),
+ message: String((error as { message?: unknown }).message ?? ""),
+ });
+}
+
async function withOneRetry(
operation: () => Promise,
sleep: (ms: number) => Promise,
@@ -201,7 +224,15 @@ export function createStartupRefreshManager(
try {
await withOneRetry(async () => {
await deps.enrichKeywords(deps.country, batch);
- }, sleep, (error) => deps.isAuthReauthRequiredError?.(error) !== true);
+ }, sleep, (error) => {
+ if (deps.isAuthReauthRequiredError?.(error) === true) {
+ return false;
+ }
+ if (isExhaustedBatchFailure(error)) {
+ return false;
+ }
+ return isTransientStartupFailure(error);
+ });
state.counters.refreshedKeywordCount += batch.length;
} catch (error) {
const isAuthReauthRequired =
diff --git a/cli/services/cache-api/services/aso-app-doc-service.test.ts b/cli/services/cache-api/services/aso-app-doc-service.test.ts
index 47262fc..95ad525 100644
--- a/cli/services/cache-api/services/aso-app-doc-service.test.ts
+++ b/cli/services/cache-api/services/aso-app-doc-service.test.ts
@@ -117,6 +117,70 @@ describe("aso-app-doc-service", () => {
);
});
+ it("fills missing date fields from itunes lookup fallback", async () => {
+ mockedAsoAppleGet
+ .mockResolvedValueOnce({
+ data: {
+ storePlatformData: {
+ "product-dv": {
+ results: {
+ "10": {
+ id: "10",
+ name: "Lookup Missing Dates",
+ userRating: { value: 4.2, ratingCount: 99 },
+ },
+ },
+ },
+ },
+ pageData: {
+ versionHistory: [],
+ },
+ },
+ } as never)
+ .mockResolvedValueOnce({
+ data: {
+ resultCount: 1,
+ results: [
+ {
+ trackId: 10,
+ wrapperType: "software",
+ trackName: "Lookup Missing Dates",
+ sellerName: "Fallback Seller",
+ releaseDate: "2024-01-01T00:00:00Z",
+ currentVersionReleaseDate: "2025-01-01T00:00:00Z",
+ },
+ ],
+ },
+ } as never);
+
+ const result = await fetchAppStoreLookupAppDocs({
+ country: "US",
+ appIds: ["10"],
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ appId: "10",
+ country: "US",
+ name: "Lookup Missing Dates",
+ releaseDate: "2024-01-01T00:00:00Z",
+ currentVersionReleaseDate: "2025-01-01T00:00:00Z",
+ }),
+ ]);
+ expect(mockedAsoAppleGet).toHaveBeenNthCalledWith(
+ 2,
+ "https://itunes.apple.com/lookup",
+ expect.objectContaining({
+ operation: "itunes.lookup",
+ params: expect.objectContaining({
+ id: "10",
+ country: "us",
+ entity: "software",
+ }),
+ })
+ );
+ });
+
it("returns cached docs first, fetches missing docs, and persists them", async () => {
const repository = createRepository({
getAppDocs: (jest.fn(async () => [
diff --git a/cli/services/cache-api/services/aso-app-doc-service.ts b/cli/services/cache-api/services/aso-app-doc-service.ts
index 13fcaa6..e6e3425 100644
--- a/cli/services/cache-api/services/aso-app-doc-service.ts
+++ b/cli/services/cache-api/services/aso-app-doc-service.ts
@@ -1,8 +1,10 @@
import { computeAppExpiryIsoForApp } from "./aso-keyword-utils";
import type { AsoCacheRepository, AsoAppDoc } from "./aso-types";
+import { normalizeCountryOnAppDocs } from "./aso-app-doc-utils";
import { asoAppleGet } from "./aso-apple-client";
import { logger } from "../../../utils/logger";
import { reportAppleContractChange } from "../../keywords/apple-http-trace";
+import { ASO_APPLE_WEB_USER_AGENT } from "../../../shared/aso-apple-http";
import {
assertSupportedCountry,
normalizeCountry,
@@ -12,6 +14,26 @@ type AppStoreProductVersionHistoryItem = {
releaseDate?: string;
};
+type ItunesLookupResult = {
+ trackId?: number;
+ trackName?: string;
+ sellerName?: string;
+ averageUserRating?: number;
+ userRatingCount?: number;
+ releaseDate?: string;
+ currentVersionReleaseDate?: string;
+ artworkUrl512?: string;
+ artworkUrl100?: string;
+ artworkUrl60?: string;
+ kind?: string;
+ wrapperType?: string;
+};
+
+type ItunesLookupResponse = {
+ resultCount?: number;
+ results?: ItunesLookupResult[];
+};
+
type AppStoreProductLookupPayload = {
storePlatformData?: {
"product-dv"?: {
@@ -55,6 +77,12 @@ function readNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
+function readString(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
function parseAppDocFromPayload(
payload: AppStoreProductLookupPayload,
fallbackAppId: string,
@@ -139,6 +167,125 @@ function parseAppDocFromPayload(
};
}
+function splitIntoChunks(items: T[], size: number): T[][] {
+ const chunkSize = Math.max(1, Math.floor(size));
+ const chunks: T[][] = [];
+ for (let idx = 0; idx < items.length; idx += chunkSize) {
+ chunks.push(items.slice(idx, idx + chunkSize));
+ }
+ return chunks;
+}
+
+function parseItunesLookupResult(
+ result: ItunesLookupResult,
+ country: string
+): AsoAppDoc | null {
+ const kind = readString(result.kind)?.toLowerCase();
+ const wrapperType = readString(result.wrapperType)?.toLowerCase();
+ if (kind !== "software" && wrapperType !== "software") {
+ return null;
+ }
+
+ const trackId =
+ typeof result.trackId === "number" ? String(result.trackId) : null;
+ if (!trackId) return null;
+ const name = readString(result.trackName) ?? "";
+ const publisherName = readString(result.sellerName) ?? undefined;
+ const releaseDate = readString(result.releaseDate);
+ const currentVersionReleaseDate = readString(result.currentVersionReleaseDate);
+ const iconUrl =
+ readString(result.artworkUrl512) ??
+ readString(result.artworkUrl100) ??
+ readString(result.artworkUrl60);
+
+ return {
+ appId: trackId,
+ country,
+ name,
+ ...(publisherName ? { publisherName } : {}),
+ averageUserRating: readNumber(result.averageUserRating),
+ userRatingCount: readNumber(result.userRatingCount),
+ releaseDate,
+ currentVersionReleaseDate,
+ ...(iconUrl ? { iconArtwork: { url: iconUrl } } : {}),
+ };
+}
+
+async function fetchItunesLookupAppDocs(params: {
+ country: string;
+ appIds: string[];
+}): Promise