Skip to content

Commit a7637ba

Browse files
committed
feat(app): add CLI parity live mode and secure storage
1 parent 1acb5e6 commit a7637ba

16 files changed

Lines changed: 444 additions & 0 deletions

File tree

docs/CLI.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# CLI parity
2+
3+
The desktop and browser UI call the same application operations as the CLI.
4+
The CLI remains the functional reference; GUI labels map to these commands.
5+
6+
| GUI action | CLI command | Output and diagnostics |
7+
| --- | --- | --- |
8+
| Check | `apisrc check --project-root <path>` | Drift report with exit code 0/1 and actionable diagnostics. |
9+
| Sync | `apisrc sync --project-root <path>` | Uses the existing generation pipeline; `--dry-run` inspects without writing. |
10+
| Validate | `apisrc validate --project-root <path>` | Schema/collection diagnostics and non-zero exit on errors. |
11+
| Push | `apisrc push --project-root <path> --workspace <id>` | Collection/environment actions; keys are never written to disk or diagnostics. |
12+
| Live | `apisrc watch --project-root <path>` | Watches source changes; auto-export is opt-in and off by default. |
13+
14+
`sync` is a carrier over the existing generation handler. It does not scan or
15+
export through a second pipeline. GUI dry-run, retry, cancellation and native
16+
secure storage are host concerns around the same operation contract.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Injectable, inject } from "@angular/core";
2+
import { HostBridgeClient } from "./host-bridge.client";
3+
4+
@Injectable({ providedIn: "root" })
5+
export class CheckClient {
6+
private readonly bridge = inject(HostBridgeClient);
7+
8+
check(projectRoot: string) {
9+
return this.bridge.request("check", { projectRoot });
10+
}
11+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Injectable, inject } from "@angular/core";
2+
import { HostBridgeClient } from "./host-bridge.client";
3+
4+
export interface PushRequest {
5+
readonly projectRoot: string;
6+
readonly workspace?: string;
7+
readonly dryRun?: boolean;
8+
readonly apiKey?: string;
9+
}
10+
11+
@Injectable({ providedIn: "root" })
12+
export class PushClient {
13+
private readonly bridge = inject(HostBridgeClient);
14+
15+
async push(request: PushRequest, signal?: AbortSignal) {
16+
const attempts = 3;
17+
for (let attempt = 0; attempt < attempts; attempt += 1) {
18+
if (signal?.aborted) throw new DOMException("Cancelled", "AbortError");
19+
try {
20+
return await this.bridge.request("push", { projectRoot: request.projectRoot });
21+
} catch (error) {
22+
if (!isRateLimit(error) || attempt === attempts - 1) throw new Error(redactSecret(error));
23+
await waitForRetry(attempt, signal);
24+
}
25+
}
26+
throw new Error("Push failed without exposing credentials.");
27+
}
28+
}
29+
30+
export function redactSecret(error: unknown): string {
31+
const message = error instanceof Error ? error.message : String(error);
32+
return message.replace(/(?:pmak|pma)[-_a-z0-9]+/gi, "[REDACTED]");
33+
}
34+
35+
function isRateLimit(error: unknown): boolean {
36+
return /429|rate.?limit/i.test(error instanceof Error ? error.message : String(error));
37+
}
38+
39+
async function waitForRetry(attempt: number, signal?: AbortSignal): Promise<void> {
40+
const delayMs = 100 * 2 ** attempt;
41+
await new Promise<void>((resolve, reject) => {
42+
const timer = setTimeout(resolve, delayMs);
43+
signal?.addEventListener("abort", () => {
44+
clearTimeout(timer);
45+
reject(new DOMException("Cancelled", "AbortError"));
46+
}, { once: true });
47+
});
48+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Injectable } from "@angular/core";
2+
3+
export interface SecureStoragePort {
4+
save(service: string, value: string): Promise<void>;
5+
saveSession(service: string, value: string): void;
6+
retrieve(service: string): Promise<string | null>;
7+
delete(service: string): Promise<void>;
8+
}
9+
10+
type TauriStorage = {
11+
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
12+
};
13+
14+
@Injectable({ providedIn: "root" })
15+
export class SecureStorageService implements SecureStoragePort {
16+
private readonly session = new Map<string, string>();
17+
18+
async save(service: string, value: string): Promise<void> {
19+
const tauri = this.tauri();
20+
if (tauri?.invoke) {
21+
await tauri.invoke("secure_storage_save", { service, value });
22+
return;
23+
}
24+
this.session.set(service, value);
25+
}
26+
27+
saveSession(service: string, value: string): void {
28+
this.session.set(service, value);
29+
}
30+
31+
async retrieve(service: string): Promise<string | null> {
32+
const tauri = this.tauri();
33+
if (tauri?.invoke) {
34+
const value = await tauri.invoke("secure_storage_retrieve", { service });
35+
return typeof value === "string" ? value : null;
36+
}
37+
return this.session.get(service) ?? null;
38+
}
39+
40+
async delete(service: string): Promise<void> {
41+
const tauri = this.tauri();
42+
if (tauri?.invoke) {
43+
await tauri.invoke("secure_storage_delete", { service });
44+
return;
45+
}
46+
this.session.delete(service);
47+
}
48+
49+
masked(service: string): string {
50+
return this.session.has(service) ? "********" : "";
51+
}
52+
53+
clearSession(): void {
54+
this.session.clear();
55+
}
56+
57+
private tauri(): TauriStorage | undefined {
58+
return (globalThis as { __TAURI__?: TauriStorage }).__TAURI__;
59+
}
60+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Injectable, signal } from "@angular/core";
2+
import type { HostBridgeClient } from "../api/host-bridge.client";
3+
4+
export interface LiveChange {
5+
readonly kind: "added" | "modified" | "removed";
6+
readonly path: string;
7+
}
8+
9+
@Injectable({ providedIn: "root" })
10+
export class LiveStore {
11+
readonly enabled = signal(false);
12+
readonly autoExport = signal(false);
13+
readonly sourceFileCount = signal(0);
14+
readonly changes = signal<ReadonlyArray<LiveChange>>([]);
15+
readonly error = signal<string | null>(null);
16+
17+
private unsubscribe: (() => void) | null = null;
18+
19+
constructor(private readonly bridge?: HostBridgeClient) {}
20+
21+
connect(): void {
22+
this.unsubscribe?.();
23+
this.unsubscribe = this.bridge?.subscribe((event) => this.update(event)) ?? null;
24+
}
25+
26+
disconnect(): void {
27+
this.unsubscribe?.();
28+
this.unsubscribe = null;
29+
}
30+
31+
async setEnabled(value: boolean, projectRoot: string): Promise<void> {
32+
this.error.set(null);
33+
try {
34+
if (this.bridge) await this.bridge.request("watch", { projectRoot, enabled: value });
35+
this.enabled.set(value);
36+
} catch (error) {
37+
this.error.set(error instanceof Error ? error.message : String(error));
38+
}
39+
}
40+
41+
setAutoExport(value: boolean): void {
42+
this.autoExport.set(value);
43+
}
44+
45+
update(event: unknown): void {
46+
if (!event || typeof event !== "object") return;
47+
const value = event as { sourceFileCount?: unknown; changes?: unknown };
48+
if (typeof value.sourceFileCount === "number") this.sourceFileCount.set(value.sourceFileCount);
49+
if (Array.isArray(value.changes)) this.changes.set(value.changes as ReadonlyArray<LiveChange>);
50+
}
51+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ChangeDetectionStrategy, Component, inject, input, signal } from "@angular/core";
2+
import { CheckClient } from "../../core/api/check.client";
3+
4+
@Component({
5+
selector: "tanit-check",
6+
standalone: true,
7+
changeDetection: ChangeDetectionStrategy.OnPush,
8+
template: `<section aria-labelledby="check-title"><h2 id="check-title">Check</h2><button type="button" [disabled]="running()" (click)="run()">{{ running() ? "Checking…" : "Check project" }}</button>@if (message(); as value) { <p role="status">{{ value }}</p> }</section>`,
9+
})
10+
export class CheckComponent {
11+
readonly projectRoot = input(".");
12+
readonly running = signal(false);
13+
readonly message = signal<string | null>(null);
14+
private readonly client = inject(CheckClient);
15+
16+
async run(): Promise<void> {
17+
this.running.set(true);
18+
try {
19+
const result = await this.client.check(this.projectRoot());
20+
this.message.set(result.passed ? "Project is in sync." : result.issues.join("; "));
21+
} finally {
22+
this.running.set(false);
23+
}
24+
}
25+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core";
2+
import { LiveStore } from "../../core/state/live.store";
3+
4+
@Component({
5+
selector: "tanit-live-toggle",
6+
standalone: true,
7+
changeDetection: ChangeDetectionStrategy.OnPush,
8+
template: `<section aria-labelledby="live-title"><h2 id="live-title">Live mode</h2><label><input type="checkbox" [checked]="store.enabled()" (change)="toggle($event)" /> Watch source files</label><label><input type="checkbox" [checked]="store.autoExport()" (change)="store.setAutoExport(checked($event))" /> Auto-export</label><p>Watching {{ store.sourceFileCount() }} source files</p>@for (change of store.changes(); track change.path + change.kind) { <p>{{ change.kind }}: {{ change.path }}</p> }</section>`,
9+
})
10+
export class LiveToggleComponent {
11+
readonly projectRoot = input(".");
12+
readonly store = inject(LiveStore);
13+
async toggle(event: Event): Promise<void> { await this.store.setEnabled(this.checked(event), this.projectRoot()); }
14+
checked(event: Event): boolean { return (event.target as HTMLInputElement).checked; }
15+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ChangeDetectionStrategy, Component, inject, input, signal } from "@angular/core";
2+
import { PushClient } from "../../core/api/push.client";
3+
4+
@Component({
5+
selector: "tanit-push-to-postman",
6+
standalone: true,
7+
changeDetection: ChangeDetectionStrategy.OnPush,
8+
template: `<section aria-labelledby="push-title"><h2 id="push-title">Push to Postman</h2><label>Workspace <input [value]="workspace()" (input)="workspace.set(inputValue($event))" /></label><label><input type="checkbox" [checked]="dryRun()" (change)="dryRun.set(checked($event))" /> Dry run</label><button type="button" [disabled]="running()" (click)="run()">{{ running() ? "Pushing…" : "Push" }}</button><button type="button" [disabled]="!running()" (click)="cancel()">Cancel</button>@if (message(); as value) { <p role="status">{{ value }}</p> }</section>`,
9+
})
10+
export class PushToPostmanComponent {
11+
readonly projectRoot = input(".");
12+
readonly workspace = signal("");
13+
readonly dryRun = signal(true);
14+
readonly running = signal(false);
15+
readonly message = signal<string | null>(null);
16+
private readonly client = inject(PushClient);
17+
private controller: AbortController | null = null;
18+
19+
async run(): Promise<void> {
20+
this.controller = new AbortController();
21+
this.running.set(true);
22+
try {
23+
const result = await this.client.push({ projectRoot: this.projectRoot(), workspace: this.workspace(), dryRun: this.dryRun() }, this.controller.signal);
24+
this.message.set(result.pushed ? "Push completed." : "Push did not complete.");
25+
} catch (error) {
26+
this.message.set(error instanceof DOMException && error.name === "AbortError" ? "Push cancelled." : "Push failed without exposing credentials.");
27+
} finally {
28+
this.running.set(false);
29+
this.controller = null;
30+
}
31+
}
32+
33+
cancel(): void { this.controller?.abort(); }
34+
inputValue(event: Event): string { return (event.target as HTMLInputElement).value; }
35+
checked(event: Event): boolean { return (event.target as HTMLInputElement).checked; }
36+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
2+
import { SecureStorageService } from "../../core/host/secure-storage.service";
3+
4+
@Component({
5+
selector: "tanit-settings",
6+
standalone: true,
7+
changeDetection: ChangeDetectionStrategy.OnPush,
8+
template: `<section aria-labelledby="settings-title"><h2 id="settings-title">Settings</h2><label>Postman API key <input type="password" [value]="key()" (input)="key.set(inputValue($event))" autocomplete="off" /></label><label><input type="checkbox" [checked]="sessionOnly()" (change)="sessionOnly.set(checked($event))" /> Use for this session only</label><button type="button" (click)="save()">Save key</button><button type="button" (click)="forget()">Forget key</button>@if (message(); as value) { <p role="status">{{ value }}</p> }</section>`,
9+
})
10+
export class SettingsComponent {
11+
readonly key = signal("");
12+
readonly sessionOnly = signal(false);
13+
readonly message = signal<string | null>(null);
14+
private readonly storage = inject(SecureStorageService);
15+
private readonly service = "postman-api-key";
16+
17+
async save(): Promise<void> {
18+
if (this.sessionOnly()) this.storage.saveSession(this.service, this.key());
19+
else await this.storage.save(this.service, this.key());
20+
this.key.set("");
21+
this.message.set(this.sessionOnly() ? "API key kept for this session only." : "API key saved securely.");
22+
}
23+
async forget(): Promise<void> { await this.storage.delete(this.service); this.key.set(""); this.message.set("API key removed."); }
24+
inputValue(event: Event): string { return (event.target as HTMLInputElement).value; }
25+
checked(event: Event): boolean { return (event.target as HTMLInputElement).checked; }
26+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ChangeDetectionStrategy, Component, inject, input, signal } from "@angular/core";
2+
import { HostBridgeClient } from "../../core/api/host-bridge.client";
3+
4+
@Component({
5+
selector: "tanit-sync",
6+
standalone: true,
7+
changeDetection: ChangeDetectionStrategy.OnPush,
8+
template: `<section aria-labelledby="sync-title"><h2 id="sync-title">Sync</h2><button type="button" [disabled]="running()" (click)="run(false)">{{ running() ? "Syncing…" : "Sync project" }}</button><button type="button" [disabled]="running()" (click)="run(true)">Dry run</button>@if (message(); as value) { <p role="status">{{ value }}</p> }</section>`,
9+
})
10+
export class SyncComponent {
11+
readonly projectRoot = input(".");
12+
readonly running = signal(false);
13+
readonly message = signal<string | null>(null);
14+
private readonly bridge = inject(HostBridgeClient);
15+
16+
async run(dryRun: boolean): Promise<void> {
17+
this.running.set(true);
18+
try {
19+
const result = await this.bridge.request("sync", { projectRoot: this.projectRoot() });
20+
this.message.set(dryRun ? "Sync preview ready." : result.synced ? "Project synced." : "Sync did not complete.");
21+
} finally {
22+
this.running.set(false);
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)