Skip to content

Commit aae7ca7

Browse files
author
StackMemory Bot (CLI)
committed
feat(sync): wire CLI login + resilient sync engine
- `stackmemory login <email> [--reset]` creates/resets workspace + saves key - Sync config reads from env vars (PROVENANT_API_KEY) or ~/.stackmemory/config.json - CloudSyncEngine: auto-create sync tables, skip missing tables gracefully - DB path resolution: check project-local first, fall back to ~/.stackmemory/ - Add /v1/setup/reset endpoint to Provenant API worker - Replace dead login.ts interactive flow with Provenant key-based login
1 parent d5411e0 commit aae7ca7

4 files changed

Lines changed: 241 additions & 27 deletions

File tree

packages/provenant-api/src/index.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export default {
4545
if (path === '/v1/setup' && request.method === 'POST') {
4646
return await handleSetup(request, sql);
4747
}
48+
if (path === '/v1/setup/reset' && request.method === 'POST') {
49+
return await handleSetupReset(request, sql);
50+
}
4851

4952
// All other endpoints require auth
5053
const authResult = await authenticate(
@@ -215,6 +218,61 @@ async function handleSetup(request, sql) {
215218
);
216219
}
217220

221+
/**
222+
* POST /v1/setup/reset
223+
* Revoke all existing keys and issue a new one.
224+
* No auth — verified by workspace ownership (email match).
225+
*/
226+
async function handleSetupReset(request, sql) {
227+
const body = await request.json();
228+
const { email } = body;
229+
230+
if (!email) {
231+
return json({ error: 'email is required' }, 400, request);
232+
}
233+
234+
// Find workspace owned by this email
235+
const ws = await sql`
236+
SELECT w.id as workspace_id, p.id as project_id
237+
FROM workspaces w
238+
JOIN projects p ON p.workspace_id = w.id
239+
WHERE w.owner_email = ${email}
240+
LIMIT 1
241+
`;
242+
243+
if (ws.length === 0) {
244+
return json({ error: 'No workspace found for this email' }, 404, request);
245+
}
246+
247+
const { workspace_id: workspaceId, project_id: projectId } = ws[0];
248+
249+
// Revoke all existing keys
250+
await sql`
251+
UPDATE api_keys SET revoked_at = NOW()
252+
WHERE workspace_id = ${workspaceId} AND revoked_at IS NULL
253+
`;
254+
255+
// Generate new key
256+
const rawKey = `smk_${randomBytes(32).toString('hex')}`;
257+
const keyHash = createHash('sha256').update(rawKey).digest('hex');
258+
259+
await sql`
260+
INSERT INTO api_keys (key_hash, user_email, project_id, workspace_id, name)
261+
VALUES (${keyHash}, ${email}, ${projectId}, ${workspaceId}, 'default')
262+
`;
263+
264+
return json(
265+
{
266+
workspaceId,
267+
projectId,
268+
apiKey: rawKey,
269+
message: 'All previous keys revoked. Save this new key.',
270+
},
271+
201,
272+
request
273+
);
274+
}
275+
218276
/**
219277
* POST /v1/workspaces/:id/keys
220278
* Create a new API key for the workspace.

src/cli/commands/sync.ts

Lines changed: 145 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,44 +13,165 @@ import Database from 'better-sqlite3';
1313
import { CloudSyncEngine } from '../../core/storage/cloud-sync.js';
1414
import type { CloudSyncConfig } from '../../core/storage/cloud-sync-types.js';
1515

16+
const DEFAULT_ENDPOINT = 'https://provenant-api.jpwu03.workers.dev';
17+
1618
function loadSyncConfig(projectDir: string): CloudSyncConfig | null {
19+
// Try env vars first (CI / advanced users)
20+
const envKey = process.env['PROVENANT_API_KEY'];
21+
const envProject = process.env['PROVENANT_PROJECT_ID'];
22+
if (envKey) {
23+
return buildConfig(
24+
envKey,
25+
envProject ||
26+
createHash('sha256').update(projectDir).digest('hex').slice(0, 16),
27+
process.env['PROVENANT_API_URL'] || DEFAULT_ENDPOINT,
28+
projectDir
29+
);
30+
}
31+
32+
// Fall back to config file
1733
const cfgPath = join(homedir(), '.stackmemory', 'config.json');
1834
if (!existsSync(cfgPath)) return null;
1935

2036
try {
2137
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
2238
if (!cfg.auth?.apiKey) return null;
2339

24-
return {
25-
enabled: true,
26-
endpoint: cfg.auth?.apiUrl || 'https://api.stackmemory.ai',
27-
apiKey: cfg.auth.apiKey,
28-
projectId: createHash('sha256')
29-
.update(projectDir)
30-
.digest('hex')
31-
.slice(0, 16),
32-
clientId: createHash('sha256')
33-
.update(hostname() + projectDir)
34-
.digest('hex')
35-
.slice(0, 16),
36-
batchSize: 100,
37-
conflictResolution: 'newest_wins',
38-
generationalPolicy: {
39-
youngMaxAgeDays: 1,
40-
matureMaxAgeDays: 7,
41-
},
42-
timeoutMs: 30000,
43-
retryAttempts: 3,
44-
retryBaseDelayMs: 1000,
45-
};
40+
return buildConfig(
41+
cfg.auth.apiKey,
42+
cfg.auth.projectId ||
43+
createHash('sha256').update(projectDir).digest('hex').slice(0, 16),
44+
cfg.auth.apiUrl || DEFAULT_ENDPOINT,
45+
projectDir
46+
);
4647
} catch {
4748
return null;
4849
}
4950
}
5051

52+
function buildConfig(
53+
apiKey: string,
54+
projectId: string,
55+
endpoint: string,
56+
projectDir: string
57+
): CloudSyncConfig {
58+
return {
59+
enabled: true,
60+
endpoint,
61+
apiKey,
62+
projectId,
63+
clientId: createHash('sha256')
64+
.update(hostname() + projectDir)
65+
.digest('hex')
66+
.slice(0, 16),
67+
batchSize: 100,
68+
conflictResolution: 'newest_wins',
69+
generationalPolicy: {
70+
youngMaxAgeDays: 1,
71+
matureMaxAgeDays: 7,
72+
},
73+
timeoutMs: 30000,
74+
retryAttempts: 3,
75+
retryBaseDelayMs: 1000,
76+
};
77+
}
78+
5179
function getDbPath(projectDir: string): string {
52-
const smDir = join(projectDir, '.stackmemory');
53-
return join(smDir, 'stackmemory.db');
80+
// Check project-local first, then ~/.stackmemory/
81+
const localDb = join(projectDir, '.stackmemory', 'stackmemory.db');
82+
if (existsSync(localDb)) return localDb;
83+
return join(homedir(), '.stackmemory', 'stackmemory.db');
84+
}
85+
86+
export function createLoginCommand(): Command {
87+
const cmd = new Command('login')
88+
.description('Connect to Provenant cloud sync')
89+
.argument('<email>', 'Your email address')
90+
.option('--workspace <name>', 'Workspace name (for new accounts)')
91+
.option('--reset', 'Reset API key (revokes existing keys)')
92+
.action(
93+
async (
94+
email: string,
95+
options: { workspace?: string; reset?: boolean }
96+
) => {
97+
const endpoint = process.env['PROVENANT_API_URL'] || DEFAULT_ENDPOINT;
98+
99+
try {
100+
const path = options.reset ? '/v1/setup/reset' : '/v1/setup';
101+
const body: Record<string, string> = { email };
102+
if (!options.reset) {
103+
body['workspaceName'] = options.workspace ?? email.split('@')[0];
104+
}
105+
106+
const res = await fetch(`${endpoint}${path}`, {
107+
method: 'POST',
108+
headers: { 'Content-Type': 'application/json' },
109+
body: JSON.stringify(body),
110+
});
111+
112+
const data = (await res.json()) as {
113+
apiKey?: string;
114+
workspaceId?: string;
115+
projectId?: string;
116+
error?: string;
117+
};
118+
119+
if (res.status === 409 && !options.reset) {
120+
// Workspace exists — suggest --reset
121+
console.log(
122+
chalk.yellow('Workspace already exists for this email.')
123+
);
124+
console.log(chalk.dim(' Run: stackmemory login <email> --reset'));
125+
console.log(chalk.dim(` Workspace ID: ${data.workspaceId}`));
126+
console.log(chalk.dim(` Project ID: ${data.projectId}`));
127+
return;
128+
}
129+
130+
if (!res.ok || !data.apiKey) {
131+
console.error(
132+
chalk.red(`Login failed: ${data.error || res.statusText}`)
133+
);
134+
process.exit(1);
135+
}
136+
137+
// Save to ~/.stackmemory/config.json
138+
const smDir = join(homedir(), '.stackmemory');
139+
const cfgPath = join(smDir, 'config.json');
140+
let cfg: Record<string, unknown> = {};
141+
if (existsSync(cfgPath)) {
142+
try {
143+
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
144+
} catch {}
145+
}
146+
147+
cfg['auth'] = {
148+
apiKey: data.apiKey,
149+
apiUrl: endpoint,
150+
projectId: data.projectId,
151+
workspaceId: data.workspaceId,
152+
email,
153+
};
154+
155+
const { writeFileSync, mkdirSync } = await import('fs');
156+
mkdirSync(smDir, { recursive: true });
157+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
158+
159+
console.log(chalk.green('Logged in to Provenant cloud sync.'));
160+
console.log(chalk.dim(` Workspace: ${data.workspaceId}`));
161+
console.log(chalk.dim(` Project: ${data.projectId}`));
162+
console.log(chalk.dim(` Config: ${cfgPath}`));
163+
} catch (err) {
164+
console.error(
165+
chalk.red(
166+
`Login failed: ${err instanceof Error ? err.message : err}`
167+
)
168+
);
169+
process.exit(1);
170+
}
171+
}
172+
);
173+
174+
return cmd;
54175
}
55176

56177
export function createSyncCommand(): Command {

src/cli/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,8 @@ import {
4848
} from './commands/decision.js';
4949
import clearCommand from './commands/clear.js';
5050
import serviceCommand from './commands/service.js';
51-
import { registerLoginCommand } from './commands/login.js';
5251
import { registerSignupCommand } from './commands/signup.js';
53-
import { createSyncCommand } from './commands/sync.js';
52+
import { createSyncCommand, createLoginCommand } from './commands/sync.js';
5453
import { registerLogoutCommand, registerDbCommands } from './commands/db.js';
5554
import { createHooksCommand } from './commands/hooks.js';
5655
import { createDaemonCommand } from './commands/daemon.js';
@@ -669,7 +668,7 @@ program
669668
// Register command modules
670669
registerOnboardingCommand(program);
671670
registerSignupCommand(program);
672-
registerLoginCommand(program);
671+
program.addCommand(createLoginCommand());
673672
registerLogoutCommand(program);
674673
registerDbCommands(program);
675674
registerProjectCommands(program);

src/core/storage/cloud-sync.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ export class CloudSyncEngine {
349349
* Get current sync status
350350
*/
351351
status(): CloudSyncStatusResponse {
352+
this.ensureSyncTables();
353+
352354
const pendingPush = this.db
353355
.prepare(
354356
`SELECT COUNT(*) as count FROM cloud_sync_state WHERE sync_status = 'pending'`
@@ -376,6 +378,7 @@ export class CloudSyncEngine {
376378
// Count rows not yet tracked in cloud_sync_state (never pushed)
377379
let untrackedCount = 0;
378380
for (const table of SYNCABLE_TABLES) {
381+
if (!this.tableExists(table)) continue;
379382
const pk = PK_COLUMN[table];
380383
const row = this.db
381384
.prepare(
@@ -400,12 +403,45 @@ export class CloudSyncEngine {
400403
};
401404
}
402405

406+
// --- Internal: Table helpers ---
407+
408+
private tableExists(name: string): boolean {
409+
const row = this.db
410+
.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`)
411+
.get(name) as unknown;
412+
return !!row;
413+
}
414+
415+
private ensureSyncTables(): void {
416+
this.db.exec(`
417+
CREATE TABLE IF NOT EXISTS cloud_sync_state (
418+
table_name TEXT NOT NULL,
419+
row_id TEXT NOT NULL,
420+
last_pushed_at INTEGER,
421+
last_pushed_version INTEGER,
422+
last_pulled_at INTEGER,
423+
last_pulled_version INTEGER,
424+
sync_status TEXT NOT NULL DEFAULT 'pending',
425+
push_error TEXT,
426+
push_attempts INTEGER NOT NULL DEFAULT 0,
427+
PRIMARY KEY (table_name, row_id)
428+
);
429+
CREATE TABLE IF NOT EXISTS cloud_sync_cursors (
430+
direction TEXT NOT NULL PRIMARY KEY,
431+
cursor_value TEXT NOT NULL,
432+
updated_at INTEGER NOT NULL
433+
);
434+
`);
435+
}
436+
403437
// --- Internal: Collect pending entities ---
404438

405439
private collectPendingEntities(): SyncEntity[] {
440+
this.ensureSyncTables();
406441
const entities: SyncEntity[] = [];
407442

408443
for (const table of SYNCABLE_TABLES) {
444+
if (!this.tableExists(table)) continue;
409445
const pk = PK_COLUMN[table];
410446
const versionCol = VERSION_COLUMN[table];
411447

0 commit comments

Comments
 (0)