Skip to content

Commit cd8c71c

Browse files
ndemiancclaude
andcommitted
Atom++ Sync (S0) + 'atom' CLI command
Cross-device sync (the Obsidian analog) by reusing Code-OSS's built-in Settings Sync: a new atom-sync extension contributes the 'atompp' authentication provider, product.json gains configurationSync.store, and tools/sync-server is a dependency-free reference server for the /v1 contract (settings/keybindings/MCP/profiles, ETag concurrency, clear/delete; 25 tests) — dev harness, free self-host tier, and the spec thin.ly implements for managed Pro. Also registers the 'atom' CLI launcher (applicationName=atom; replaces the earlier mono experiment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1dfb5dc commit cd8c71c

12 files changed

Lines changed: 1180 additions & 1 deletion

File tree

‎branding/product.overlay.json‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
"nameShort": "Atom++",
55
"nameLong": "Atom++",
6-
"applicationName": "atom-plus-plus",
6+
"applicationName": "atom",
77
"serverApplicationName": "atom-plus-plus-server",
88
"serverDataFolderName": ".atom-plus-plus-server",
99
"dataFolderName": ".atom-plus-plus",
@@ -23,6 +23,17 @@
2323
"urlProtocol": "atom-plus-plus",
2424
"quality": "stable",
2525

26+
"_syncComment": "Atom++ Sync (the Obsidian analog). Reuses Code-OSS's built-in Settings Sync, pointed at our own server. The `atompp` auth provider is contributed by extensions/atom-sync. RELEASE NOTE: url is a DEV/localhost placeholder — point it at the managed Atom++ Cloud sync host before any release (see docs/atompp-sync-design.md §6).",
27+
"configurationSync.store": {
28+
"url": "http://localhost:9595",
29+
"stableUrl": "http://localhost:9595",
30+
"insidersUrl": "http://localhost:9595",
31+
"canSwitch": false,
32+
"authenticationProviders": {
33+
"atompp": { "scopes": ["sync"] }
34+
}
35+
},
36+
2637
"licenseName": "MIT",
2738
"licenseUrl": "https://github.com/systemu-net/atom-plus-plus/blob/main/LICENSE",
2839
"reportIssueUrl": "https://github.com/systemu-net/atom-plus-plus/issues/new",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Atom++ Sync Test Suite Summary
2+
3+
## Overview
4+
Comprehensive synchronous test suite for `atom-plus-plus/extensions/atom-sync/session.js` — the pure, unit-testable session management module for Atom++ authentication and sync.
5+
6+
## Test Execution
7+
Run tests with:
8+
```bash
9+
cd atom-plus-plus/extensions/atom-sync && node test/session.test.js
10+
```
11+
12+
## Test Coverage (36 tests total)
13+
14+
### Core Functionality Tests (8 baseline)
15+
1. **SCOPES validation** — `SCOPES` array contains the expected `["sync"]` scope
16+
2. **mintDevToken determinism** — Same email produces same token (enables cross-device reach); different emails produce different tokens
17+
3. **Session shape** — `makeSession()` creates proper `vscode.AuthenticationSession`-like objects with id, accessToken, account, scopes
18+
4. **Session ID stability** — Same token produces same session ID; different tokens produce different IDs
19+
5. **Metadata serialization** — Tokens never leak into serialized metadata; `parseMeta()` round-trips correctly
20+
6. **Metadata parsing resilience** — Handles garbage input (malformed JSON, non-arrays, null, empty strings)
21+
7. **Scope matching** — Sessions satisfy requested scopes (empty/undefined requests match; subset required)
22+
8. **Email validation** — Rejects invalid formats, accepts valid ones
23+
24+
### Advanced Edge Cases (28 comprehensive tests)
25+
26+
#### Token Generation
27+
- **Different emails** produce different tokens
28+
- **Case-insensitivity** — email case variations normalize to same token
29+
- **Whitespace normalization** — leading/trailing spaces trimmed
30+
- **Token structure** — `atmps_` prefix + 64-char SHA256 hex = 70 chars total
31+
32+
#### Session Management
33+
- **Custom scopes** override defaults
34+
- **Null/undefined scopes** default to `SCOPES`
35+
- **Empty scope arrays** fall back to defaults
36+
- **Token-based ID** — ID depends on token only, not email
37+
- **Email case handling** — account.id lowercased; account.label preserves case
38+
- **Label case preservation** — account labels maintain original user input
39+
40+
#### Metadata Handling
41+
- **Multiple sessions** serialize and round-trip correctly
42+
- **Account + scopes** preserved per session
43+
- **Empty, null, undefined inputs** parsed safely
44+
- **Malformed JSON** returns empty array (fail-safe)
45+
- **Non-array objects** ignored (incorrect shape)
46+
47+
#### Scope Matching
48+
- **Exact matches** work
49+
- **Missing required scopes** fail
50+
- **Sessions without scopes** reject any scope requirement
51+
- **Order independence** — scope order doesn't matter
52+
53+
#### Email Validation
54+
- **Valid formats** — user@domain.ext, with dots/plus signs
55+
- **Invalid formats** — missing @, missing TLD, spaces, empty, non-strings
56+
- **Edge cases** — whitespace-only, no domain name, single-letter parts OK
57+
58+
#### Security & Consistency
59+
- **Cross-session consistency** — same email always reachable
60+
- **Session isolation** — deterministic tokens enable reproducibility
61+
- **Metadata security** — tokens absent from serialized data (no base64, hex, or plaintext leaks)
62+
63+
## Test Structure
64+
- **Framework** — Node.js built-in `assert` module
65+
- **Style** — Synchronous, pure functions (no mocks, no async)
66+
- **Input coverage** — valid inputs, invalid inputs, edge cases, security boundaries
67+
- **Output validation** — exact type/structure checks, deep equality, determinism
68+
69+
## Key Design Insights Tested
70+
1. **DEV tokens are deterministic** — `mintDevToken('a@b.co')` always returns the same value, enabling cross-device sync in development
71+
2. **Email normalization** — case and whitespace normalized for consistency, but original case preserved in account.label for UX
72+
3. **Token security** — tokens stored in `SecretStorage` only; metadata (saved to globalState) never includes tokens
73+
4. **Scope matching** — sessions must satisfy ALL requested scopes (superset requirement)
74+
5. **Fail-safe parsing** — malformed metadata gracefully degrades to empty array, never throws
75+
76+
## Future Enhancements
77+
- Integration tests with actual vscode auth provider (requires extension activation context)
78+
- Storage/retrieval tests for SecretStorage + globalState (requires vscode APIs)
79+
- Real OAuth/PKCE flow tests (once S6.2.3 implementation lands)
80+
- Concurrent session management under load

‎extensions/atom-sync/extension.js‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Atom++ — Sync
3+
*
4+
* Contributes the `atompp` authentication provider that the editor's BUILT-IN Settings Sync
5+
* uses (matched via product.json → configurationSync.store.authenticationProviders). Once
6+
* signed in + turned on, the editor syncs settings/keybindings/snippets/theme/MCP/UI-state to
7+
* the configured sync server using its own battle-tested engine — we add no sync logic here.
8+
*
9+
* Sign-in is a DEV flow for now (mints a local token); the real loopback+PKCE flow against
10+
* Atom++ Cloud (PRD §6.2.3) drops into createSession() later. See docs/atompp-sync-design.md.
11+
*--------------------------------------------------------------------------------------------*/
12+
// @ts-check
13+
'use strict';
14+
15+
const vscode = require('vscode');
16+
const S = require('./session');
17+
18+
const PROVIDER_ID = 'atompp';
19+
const PROVIDER_LABEL = 'Atom++ Account';
20+
const SECRET_PREFIX = 'atompp.sync.token.'; // + session.id → token in SecretStorage (keychain)
21+
const META_KEY = 'atompp.sync.sessions'; // globalState → [{id, account, scopes}] (no tokens)
22+
23+
/** @type {vscode.ExtensionContext} */
24+
let ctx;
25+
/** @type {vscode.StatusBarItem} */
26+
let statusItem;
27+
const sessionsChanged = new vscode.EventEmitter();
28+
29+
/** Load full sessions: metadata from globalState + token from SecretStorage. */
30+
async function loadSessions() {
31+
const meta = S.parseMeta(ctx.globalState.get(META_KEY, '[]'));
32+
const out = [];
33+
for (const m of meta) {
34+
const token = await ctx.secrets.get(SECRET_PREFIX + m.id);
35+
if (token) { out.push({ id: m.id, accessToken: token, account: m.account, scopes: m.scopes }); }
36+
}
37+
return out;
38+
}
39+
40+
async function addSession(session) {
41+
const sessions = await loadSessions();
42+
const next = sessions.filter(s => s.id !== session.id).concat([session]);
43+
await ctx.secrets.store(SECRET_PREFIX + session.id, session.accessToken);
44+
await ctx.globalState.update(META_KEY, S.serializeMeta(next));
45+
}
46+
47+
async function deleteSession(id) {
48+
const sessions = await loadSessions();
49+
const removed = sessions.find(s => s.id === id) || null;
50+
const next = sessions.filter(s => s.id !== id);
51+
await ctx.secrets.delete(SECRET_PREFIX + id);
52+
await ctx.globalState.update(META_KEY, S.serializeMeta(next));
53+
return removed;
54+
}
55+
56+
async function currentSession() {
57+
const sessions = await loadSessions();
58+
return sessions[0] || null;
59+
}
60+
61+
/** @type {vscode.AuthenticationProvider} */
62+
const provider = {
63+
onDidChangeSessions: sessionsChanged.event,
64+
async getSessions(scopes) {
65+
const sessions = await loadSessions();
66+
return sessions.filter(s => S.scopesMatch(s, scopes ? Array.from(scopes) : undefined));
67+
},
68+
async createSession(scopes) {
69+
const email = await vscode.window.showInputBox({
70+
title: 'Sign in to Atom++ Sync',
71+
prompt: 'Your Atom++ account email — dev sign-in for now (real OAuth/PKCE lands later)',
72+
placeHolder: 'you@example.com',
73+
ignoreFocusOut: true,
74+
validateInput: (v) => (S.validateEmail(v) ? undefined : 'Enter a valid email address')
75+
});
76+
if (!email) { throw new Error('Atom++ Sync sign-in cancelled'); }
77+
const token = S.mintDevToken(email.trim());
78+
const session = S.makeSession(email.trim(), token, scopes ? Array.from(scopes) : S.SCOPES);
79+
await addSession(session);
80+
sessionsChanged.fire({ added: [session], removed: [], changed: [] });
81+
updateStatus(session);
82+
return session;
83+
},
84+
async removeSession(sessionId) {
85+
const removed = await deleteSession(sessionId);
86+
if (removed) { sessionsChanged.fire({ added: [], removed: [removed], changed: [] }); }
87+
updateStatus(null);
88+
}
89+
};
90+
91+
function updateStatus(sessionOrNull) {
92+
if (!statusItem) { return; }
93+
if (!vscode.workspace.getConfiguration('atompp.sync').get('showStatusBar', true)) {
94+
statusItem.hide();
95+
return;
96+
}
97+
if (sessionOrNull) {
98+
statusItem.text = '$(sync) ' + sessionOrNull.account.label;
99+
statusItem.tooltip = 'Atom++ Sync — signed in. Click for status.';
100+
statusItem.command = 'atompp.sync.status';
101+
} else {
102+
statusItem.text = '$(sync-ignored) Atom++ Sync';
103+
statusItem.tooltip = 'Atom++ Sync — not signed in. Click to set up.';
104+
statusItem.command = 'atompp.sync.setUp';
105+
}
106+
statusItem.show();
107+
}
108+
109+
async function activate(context) {
110+
ctx = context;
111+
112+
// Registered here (onStartupFinished) so the status bar reflects state at launch — but also
113+
// lazily on demand: when Settings Sync requests a session, the implicit
114+
// `onAuthenticationRequest:atompp` activation fires, so timing relative to sync is never a concern.
115+
context.subscriptions.push(
116+
vscode.authentication.registerAuthenticationProvider(PROVIDER_ID, PROVIDER_LABEL, provider, { supportsMultipleAccounts: false })
117+
);
118+
119+
statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50);
120+
context.subscriptions.push(statusItem);
121+
updateStatus(await currentSession());
122+
123+
context.subscriptions.push(vscode.commands.registerCommand('atompp.sync.signIn', async () => {
124+
const session = await vscode.authentication.getSession(PROVIDER_ID, S.SCOPES, { createIfNone: true });
125+
if (session) { vscode.window.showInformationMessage('Atom++ Sync: signed in as ' + session.account.label + '.'); }
126+
}));
127+
128+
context.subscriptions.push(vscode.commands.registerCommand('atompp.sync.setUp', async () => {
129+
const session = await vscode.authentication.getSession(PROVIDER_ID, S.SCOPES, { createIfNone: true });
130+
if (!session) { return; }
131+
// Hand off to the editor's built-in Settings Sync (uses our provider + product.json store).
132+
try {
133+
await vscode.commands.executeCommand('workbench.userDataSync.actions.turnOn');
134+
} catch (e) {
135+
vscode.window.showWarningMessage(
136+
'Signed in. Now run "Settings Sync: Turn On" to start syncing. (' + (e && e.message ? e.message : String(e)) + ')'
137+
);
138+
}
139+
}));
140+
141+
context.subscriptions.push(vscode.commands.registerCommand('atompp.sync.signOut', async () => {
142+
const session = await currentSession();
143+
if (!session) { vscode.window.showInformationMessage('Atom++ Sync: not signed in.'); return; }
144+
await provider.removeSession(session.id);
145+
vscode.window.showInformationMessage('Atom++ Sync: signed out.');
146+
}));
147+
148+
context.subscriptions.push(vscode.commands.registerCommand('atompp.sync.status', async () => {
149+
const session = await currentSession();
150+
vscode.window.showInformationMessage(
151+
session
152+
? 'Atom++ Sync: signed in as ' + session.account.label + '. Sync server is configured in product.json → configurationSync.store.'
153+
: 'Atom++ Sync: not signed in. Run "Atom++ Sync: Set Up Sync".'
154+
);
155+
}));
156+
157+
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async (e) => {
158+
if (e.affectsConfiguration('atompp.sync.showStatusBar')) { updateStatus(await currentSession()); }
159+
}));
160+
}
161+
162+
function deactivate() { }
163+
164+
module.exports = { activate, deactivate };

‎extensions/atom-sync/package.json‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "atom-sync",
3+
"displayName": "Atom++ Sync",
4+
"description": "Sign in to Atom++ and sync your settings, keybindings, theme, snippets, and MCP config across machines — powered by the editor's built-in Settings Sync.",
5+
"publisher": "atompp",
6+
"version": "0.1.0",
7+
"license": "MIT",
8+
"engines": { "vscode": "^1.96.0" },
9+
"categories": ["Other"],
10+
"capabilities": {
11+
"virtualWorkspaces": true,
12+
"untrustedWorkspaces": { "supported": true }
13+
},
14+
"activationEvents": ["onStartupFinished"],
15+
"main": "./extension.js",
16+
"contributes": {
17+
"authentication": [
18+
{ "id": "atompp", "label": "Atom++ Account" }
19+
],
20+
"commands": [
21+
{ "command": "atompp.sync.setUp", "title": "Set Up Sync (Sign In + Turn On)", "category": "Atom++ Sync" },
22+
{ "command": "atompp.sync.signIn", "title": "Sign In", "category": "Atom++ Sync" },
23+
{ "command": "atompp.sync.signOut", "title": "Sign Out", "category": "Atom++ Sync" },
24+
{ "command": "atompp.sync.status", "title": "Show Status", "category": "Atom++ Sync" }
25+
],
26+
"configuration": {
27+
"title": "Atom++ Sync",
28+
"properties": {
29+
"atompp.sync.showStatusBar": {
30+
"type": "boolean",
31+
"default": true,
32+
"description": "Show the Atom++ Sync account indicator in the status bar."
33+
}
34+
}
35+
}
36+
}
37+
}

‎extensions/atom-sync/session.js‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Atom++ — Sync : pure session helpers (no vscode import, unit-testable with `node`).
3+
*--------------------------------------------------------------------------------------------*/
4+
// @ts-check
5+
'use strict';
6+
7+
const crypto = require('crypto');
8+
9+
/** The OAuth-style scopes our auth provider issues. Must match product.json authenticationProviders.atompp.scopes. */
10+
const SCOPES = ['sync'];
11+
12+
/**
13+
* Mint a local DEV bearer token for an account. The real loopback+PKCE flow against
14+
* Atom++ Cloud (PRD §6.2.3) will replace this — only this function changes.
15+
* Shape: `atmps_<base64url(email)>.<random>` — opaque to the editor; the dev server
16+
* isolates storage per token.
17+
* @param {string} email
18+
* @returns {string}
19+
*/
20+
function mintDevToken(email) {
21+
// DEV token: a one-way hash of the email — DETERMINISTIC, so signing in with the same email
22+
// on another machine reaches the same account (that's what makes cross-device sync testable),
23+
// yet the email is NOT recoverable from the token (no PII in the credential). This is NOT a
24+
// security boundary; the real loopback+PKCE flow (S1) issues an opaque server-side token.
25+
return 'atmps_' + crypto.createHash('sha256').update('atompp-sync-dev:' + String(email).toLowerCase().trim()).digest('hex');
26+
}
27+
28+
/**
29+
* Build a `vscode.AuthenticationSession`-shaped object.
30+
* @param {string} email
31+
* @param {string} token
32+
* @param {readonly string[]} [scopes]
33+
*/
34+
function makeSession(email, token, scopes) {
35+
const id = 'atompp-' + crypto.createHash('sha256').update(token).digest('hex').slice(0, 16);
36+
const list = scopes && scopes.length ? Array.from(scopes) : SCOPES.slice();
37+
return {
38+
id,
39+
accessToken: token,
40+
account: { id: String(email).toLowerCase(), label: String(email) },
41+
scopes: list
42+
};
43+
}
44+
45+
/**
46+
* Serialize session METADATA only (never the token — tokens live in SecretStorage).
47+
* @param {Array<{id:string, account:{id:string,label:string}, scopes:string[]}>} sessions
48+
*/
49+
function serializeMeta(sessions) {
50+
return JSON.stringify(sessions.map(s => ({ id: s.id, account: s.account, scopes: s.scopes })));
51+
}
52+
53+
/** @param {string} json */
54+
function parseMeta(json) {
55+
try {
56+
const arr = JSON.parse(json || '[]');
57+
return Array.isArray(arr) ? arr : [];
58+
} catch {
59+
return [];
60+
}
61+
}
62+
63+
/**
64+
* Whether a stored session satisfies a requested scope list.
65+
* @param {{scopes?: string[]}} session
66+
* @param {readonly string[]} [requested]
67+
*/
68+
function scopesMatch(session, requested) {
69+
if (!requested || requested.length === 0) { return true; }
70+
const have = new Set(session.scopes || []);
71+
return Array.from(requested).every(s => have.has(s));
72+
}
73+
74+
/** @param {unknown} s */
75+
function validateEmail(s) {
76+
return typeof s === 'string' && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s.trim());
77+
}
78+
79+
module.exports = { SCOPES, mintDevToken, makeSession, serializeMeta, parseMeta, scopesMatch, validateEmail };

0 commit comments

Comments
 (0)