diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index b3dec1b..a805b33 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -126,6 +126,32 @@ function makeAuthRouter({ authService, storageService }) {
}
});
+ router.get('/storage/discover', requireAuth, async (req, res) => {
+ try {
+ const provider = req.query.provider || 'github';
+ if (provider !== 'github') {
+ return res.status(400).json({
+ error: 'Only provider=github is supported in Phase 1',
+ });
+ }
+
+ const clients = await authService.clientsFor(req.user);
+ if (!clients.githubUserClient && !clients.githubClient) {
+ return res.status(400).json({ error: 'GitHub client is not available' });
+ }
+
+ const result = await storageService.discoverAccountStores(provider, clients, {
+ sessionStorageRef: req.user?.storage || null,
+ });
+ return res.json(result);
+ } catch (err) {
+ console.error(err);
+ return res.status(500).json({
+ error: err.message || 'Failed to discover storage',
+ });
+ }
+ });
+
router.post('/storage/create', requireAuth, async (req, res) => {
try {
const { name, provider = 'github' } = req.body || {};
@@ -134,9 +160,6 @@ function makeAuthRouter({ authService, storageService }) {
error: 'Only provider=github is supported for repository creation',
});
}
- if (!name || typeof name !== 'string') {
- return res.status(400).json({ error: 'name is required' });
- }
const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
@@ -144,9 +167,10 @@ function makeAuthRouter({ authService, storageService }) {
}
const installUrl = await authService.getInstallationSetupUrl();
- const result = await storageService.createGitHubRepository(name, clients, {
- installUrl,
- });
+ const result =
+ typeof name === 'string' && name.trim()
+ ? await storageService.createGitHubRepository(name.trim(), clients, { installUrl })
+ : await storageService.createNextVizablyGitHubRepository(clients, { installUrl });
return res.status(201).json({
provider: 'github',
storageRef: result.storageRef,
diff --git a/backend/services/storageService.js b/backend/services/storageService.js
index ee46cc0..833abf6 100644
--- a/backend/services/storageService.js
+++ b/backend/services/storageService.js
@@ -7,7 +7,12 @@
*/
const crypto = require('crypto');
const { randomUUID } = require('crypto');
-const { normalizeGitHubRepoName } = require('../../shared/githubRepoName');
+const {
+ applyVizablyRepoPrefix,
+ nextVizablyStoreName,
+ normalizeGitHubRepoName,
+ VIZABLY_DEFAULT_STORE_NAME,
+} = require('../../shared/githubRepoName');
const { collectAllGitHubPages, findInGitHubPages } = require('./githubPagination');
const MANIFEST_PATH = 'vizably.json';
@@ -160,7 +165,7 @@ class StorageService {
* Create a private empty GitHub repo for the signed-in user (App UAT).
* Does not initialize a Vizably store — caller runs fit-check then init.
*
- * @param {string} name repository name (not owner/name)
+ * @param {string} name repository name (not owner/name); stored as `viz_`
* @param {StorageClients} clients must include githubUserClient (or githubClient as UAT)
* @param {object} [options]
* @param {string} [options.installUrl] App install URL when needsInstall
@@ -215,6 +220,162 @@ class StorageService {
return { storageRef, needsInstall, installUrl };
}
+ /**
+ * Create the next unused default store: `viz_scans`, then `viz_scans-2`, …
+ *
+ * @param {StorageClients} clients
+ * @param {object} [options]
+ * @param {string} [options.installUrl]
+ */
+ async createNextVizablyGitHubRepository(clients, options = {}) {
+ const taken = [];
+ for (let n = 1; n <= 50; n += 1) {
+ const name = nextVizablyStoreName(taken);
+ try {
+ return await this.createGitHubRepository(name, clients, options);
+ } catch (err) {
+ if (err.code === 'REPO_NAME_TAKEN') {
+ taken.push(name);
+ continue;
+ }
+ throw err;
+ }
+ }
+ const err = new Error('Could not find an available Vizably repository name');
+ err.status = 422;
+ err.code = 'REPO_NAME_TAKEN';
+ throw err;
+ }
+
+ /**
+ * Find existing Vizably account stores (manifest-based, provider-neutral).
+ * Order: session storageRef → GET expected name (`viz_scans`) → list repos.
+ *
+ * @param {'github' | 'google'} provider
+ * @param {StorageClients} clients
+ * @param {object} [options]
+ * @param {object} [options.sessionStorageRef]
+ * @returns {Promise<{
+ * provider: string,
+ * stores: Array<{ storageRef: object, validation: object }>,
+ * source: 'session' | 'expected-name' | 'list' | null,
+ * }>}
+ */
+ async discoverAccountStores(provider, clients, options = {}) {
+ if (provider === 'google') {
+ return { provider, stores: [], source: null };
+ }
+ if (provider !== 'github') {
+ throw new Error('Unsupported storage provider');
+ }
+ return this._discoverGitHubAccountStores(clients, options);
+ }
+
+ /**
+ * @param {StorageClients} clients
+ * @param {{ sessionStorageRef?: object }} [options]
+ * @private
+ */
+ async _discoverGitHubAccountStores(clients, { sessionStorageRef } = {}) {
+ const octokit = clients.githubUserClient ?? clients.githubClient;
+ if (!octokit) {
+ throw new Error('GitHub client is required to discover storage');
+ }
+
+ const consider = async (storageRef) => {
+ const validation = await this.validateStorage('github', storageRef, clients);
+ if (!this._isDiscoveredAccountStore(validation)) {
+ return null;
+ }
+ return {
+ storageRef: {
+ id: storageRef.id,
+ full_name: storageRef.full_name,
+ html_url: storageRef.html_url,
+ name: storageRef.name || storageRef.full_name?.split('/')[1],
+ },
+ validation,
+ };
+ };
+
+ if (sessionStorageRef?.full_name || sessionStorageRef?.id) {
+ try {
+ const hit = await consider(sessionStorageRef);
+ if (hit) {
+ return { provider: 'github', stores: [hit], source: 'session' };
+ }
+ } catch {
+ // Stale session ref — fall through to name GET / listing.
+ }
+ }
+
+ let owner;
+ try {
+ const { data: user } = await octokit.rest.users.getAuthenticated();
+ owner = user.login;
+ } catch (err) {
+ throw new Error(
+ err?.status === 401
+ ? 'GitHub authentication failed. Sign out and sign in again.'
+ : 'Could not look up your GitHub username.',
+ );
+ }
+
+ try {
+ const { data: repo } = await octokit.rest.repos.get({
+ owner,
+ repo: VIZABLY_DEFAULT_STORE_NAME,
+ });
+ const hit = await consider({
+ id: repo.node_id,
+ full_name: repo.full_name,
+ html_url: repo.html_url,
+ name: repo.name,
+ });
+ if (hit) {
+ return { provider: 'github', stores: [hit], source: 'expected-name' };
+ }
+ } catch (err) {
+ if (err?.status === 429 || (err?.status === 403 && /rate limit/i.test(String(err.message)))) {
+ throw err;
+ }
+ }
+
+ const repos = await this.listGitHubRepos(octokit);
+ const stores = [];
+ for (const repo of repos) {
+ try {
+ const hit = await consider({
+ id: repo.id,
+ full_name: repo.full_name,
+ html_url: repo.html_url,
+ });
+ if (hit) {
+ stores.push(hit);
+ }
+ } catch {
+ // Skip repos we cannot inspect.
+ }
+ }
+ return { provider: 'github', stores, source: 'list' };
+ }
+
+ /**
+ * A discovered account store is identified by vizably.json (or legacy
+ * equalview.json), never by repository name.
+ * @param {{ status?: string, reason?: string | null }} validation
+ * @private
+ */
+ _isDiscoveredAccountStore(validation) {
+ if (!validation) {
+ return false;
+ }
+ if (validation.status === 'loadable' || validation.status === 'incompatible') {
+ return true;
+ }
+ return validation.status === 'invalid' && validation.reason === 'malformed_manifest';
+ }
+
/**
* True when a Vizably App installation with Contents write includes this repo.
* Does not fall back to the user's personal push bit — create needs the App.
@@ -390,7 +551,7 @@ class StorageService {
throw err;
}
- const normalized = normalizeGitHubRepoName(name);
+ const normalized = applyVizablyRepoPrefix(name);
if (!normalized) {
const err = new Error('Repository name is required');
err.status = 400;
diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js
index 36b0998..c9720ca 100644
--- a/backend/tests/auth.test.js
+++ b/backend/tests/auth.test.js
@@ -388,18 +388,64 @@ test('POST /api/auth/storage/create returns storageRef and needsInstall', async
assert.match(res.body.installUrl, /installations\/new/);
});
-test('POST /api/auth/storage/create requires name', async () => {
+test('GET /api/auth/storage/discover returns discovered stores', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
- clientsFor: async () => ({ githubUserClient: {} }),
- getInstallationSetupUrl: async () => 'https://github.com/settings/installations',
+ clientsFor: async () => ({ githubUserClient: { mock: true } }),
+ },
+ storageService: {
+ discoverAccountStores: async (provider, _clients, options) => {
+ assert.equal(provider, 'github');
+ assert.equal(options.sessionStorageRef, AUTHED_USER.storage);
+ return {
+ provider: 'github',
+ stores: [
+ {
+ storageRef: {
+ id: 'R_kg',
+ full_name: 'sam/viz_scans',
+ html_url: 'https://github.com/sam/viz_scans',
+ },
+ validation: { status: 'loadable' },
+ },
+ ],
+ source: 'expected-name',
+ };
+ },
},
- storageService: {},
});
- const res = await request(app).post('/api/auth/storage/create').send({});
- assert.equal(res.status, 400);
- assert.match(res.body.error, /name is required/);
+ const res = await request(app).get('/api/auth/storage/discover?provider=github');
+ assert.equal(res.status, 200);
+ assert.equal(res.body.source, 'expected-name');
+ assert.equal(res.body.stores[0].storageRef.full_name, 'sam/viz_scans');
+});
+
+test('POST /api/auth/storage/create uses the default store when name is omitted', async () => {
+ const app = createAuthedApp({
+ user: AUTHED_USER,
+ authService: {
+ clientsFor: async () => ({ githubUserClient: { mock: true } }),
+ getInstallationSetupUrl: async () =>
+ 'https://github.com/apps/vizably/installations/new',
+ },
+ storageService: {
+ createNextVizablyGitHubRepository: async (_clients, options) => ({
+ storageRef: {
+ id: 'R_kgNew',
+ name: 'viz_scans',
+ full_name: 'sam/viz_scans',
+ private: true,
+ html_url: 'https://github.com/sam/viz_scans',
+ },
+ needsInstall: false,
+ installUrl: options.installUrl,
+ }),
+ },
+ });
+ const res = await request(app).post('/api/auth/storage/create').send({ provider: 'github' });
+ assert.equal(res.status, 201);
+ assert.equal(res.body.storageRef.name, 'viz_scans');
});
test('POST /api/auth/storage/create returns probe failures without needsInstall', async () => {
diff --git a/backend/tests/githubRepoName.test.js b/backend/tests/githubRepoName.test.js
index 105255f..e94645a 100644
--- a/backend/tests/githubRepoName.test.js
+++ b/backend/tests/githubRepoName.test.js
@@ -1,9 +1,13 @@
/**
- * Unit tests for shared GitHub repo name normalization (#85).
+ * Unit tests for shared GitHub repository name helpers.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
-const { normalizeGitHubRepoName } = require('../../shared/githubRepoName');
+const {
+ normalizeGitHubRepoName,
+ applyVizablyRepoPrefix,
+ VIZABLY_REPO_PREFIX,
+} = require('../../shared/githubRepoName');
test('normalizeGitHubRepoName trims leading and trailing whitespace', () => {
assert.equal(normalizeGitHubRepoName(' vizably-scans '), 'vizably-scans');
@@ -23,3 +27,30 @@ test('normalizeGitHubRepoName returns empty for whitespace-only input', () => {
assert.equal(normalizeGitHubRepoName(null), '');
assert.equal(normalizeGitHubRepoName(undefined), '');
});
+
+test('applyVizablyRepoPrefix prepends viz_ after normalizing', () => {
+ assert.equal(applyVizablyRepoPrefix('scans'), 'viz_scans');
+ assert.equal(applyVizablyRepoPrefix(' accessibility results '), 'viz_accessibility-results');
+ assert.equal(applyVizablyRepoPrefix('reports'), `${VIZABLY_REPO_PREFIX}reports`);
+});
+
+test('applyVizablyRepoPrefix is idempotent when the prefix is already present', () => {
+ assert.equal(applyVizablyRepoPrefix('viz_scans'), 'viz_scans');
+ assert.equal(applyVizablyRepoPrefix('VIZ_reports'), 'viz_reports');
+ assert.equal(applyVizablyRepoPrefix(' viz_my-repo '), 'viz_my-repo');
+});
+
+test('applyVizablyRepoPrefix returns empty for blank input', () => {
+ assert.equal(applyVizablyRepoPrefix(' '), '');
+ assert.equal(applyVizablyRepoPrefix(null), '');
+});
+
+test('nextVizablyStoreName starts at viz_scans then increments', () => {
+ const {
+ nextVizablyStoreName,
+ VIZABLY_DEFAULT_STORE_NAME,
+ } = require('../../shared/githubRepoName');
+ assert.equal(nextVizablyStoreName([]), VIZABLY_DEFAULT_STORE_NAME);
+ assert.equal(nextVizablyStoreName(['viz_scans']), 'viz_scans-2');
+ assert.equal(nextVizablyStoreName(['viz_scans', 'viz_scans-2']), 'viz_scans-3');
+});
diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js
index 883b313..b3449dc 100644
--- a/backend/tests/storageService.test.js
+++ b/backend/tests/storageService.test.js
@@ -95,6 +95,9 @@ function createMockGitHubClient(initial = {}) {
return { data: repoMeta };
},
createForAuthenticatedUser: async ({ name, private: isPrivate, auto_init }) => {
+ if (typeof initial.createRepo === 'function') {
+ return initial.createRepo({ name, private: isPrivate, auto_init });
+ }
if (initial.createRepoError) {
throw initial.createRepoError;
}
@@ -350,7 +353,8 @@ test('checkGitHubRepoNameAvailability returns available on 404', async () => {
githubUserClient: client,
});
assert.equal(result.status, 'available');
- assert.equal(result.full_name, 'sam/fresh-repo');
+ assert.equal(result.normalizedName, 'viz_fresh-repo');
+ assert.equal(result.full_name, 'sam/viz_fresh-repo');
});
test('checkGitHubRepoNameAvailability returns taken when repo exists', async () => {
@@ -364,7 +368,8 @@ test('checkGitHubRepoNameAvailability returns taken when repo exists', async ()
githubUserClient: client,
});
assert.equal(result.status, 'taken');
- assert.match(result.message, /already exists/);
+ assert.equal(result.normalizedName, 'viz_site-audits');
+ assert.match(result.message, /viz_site-audits/);
});
test('checkGitHubRepoNameAvailability returns invalid for bad names', async () => {
@@ -384,14 +389,15 @@ test('createGitHubRepository creates a private empty repo and returns storageRef
{
id: 1,
contents: 'write',
- repos: ['sam/vizably-new'],
+ repos: ['sam/viz_scans'],
},
],
});
- const result = await storageService.createGitHubRepository('vizably-new', {
+ const result = await storageService.createGitHubRepository('scans', {
githubUserClient: client,
});
- assert.equal(result.storageRef.full_name, 'sam/vizably-new');
+ assert.equal(result.storageRef.full_name, 'sam/viz_scans');
+ assert.equal(result.storageRef.name, 'viz_scans');
assert.equal(result.storageRef.id, 'R_kgNew');
assert.equal(result.needsInstall, false);
assert.equal(result.installUrl, null);
@@ -409,11 +415,12 @@ test('createGitHubRepository sets needsInstall when App cannot write yet', async
],
});
const result = await storageService.createGitHubRepository(
- 'vizably-new',
+ 'scans',
{ githubUserClient: client },
{ installUrl: 'https://github.com/apps/vizably/installations/new' },
);
assert.equal(result.needsInstall, true);
+ assert.equal(result.storageRef.name, 'viz_scans');
assert.equal(
result.installUrl,
'https://github.com/apps/vizably/installations/new',
@@ -432,16 +439,17 @@ test('createGitHubRepository skips install hop when installation covers all repo
},
],
});
- const result = await storageService.createGitHubRepository('vizably-new', {
+ const result = await storageService.createGitHubRepository('scans', {
githubUserClient: client,
});
assert.equal(result.needsInstall, false);
+ assert.equal(result.storageRef.name, 'viz_scans');
});
test('createGitHubRepository finds writable install when repo is past first page', async () => {
const storageService = new StorageService();
const repos = Array.from({ length: 101 }, (_, i) =>
- i === 100 ? 'sam/vizably-new' : `sam/other-${i}`,
+ i === 100 ? 'sam/viz_paged' : `sam/other-${i}`,
);
const client = createMockGitHubClient({
installationProbe: [
@@ -452,10 +460,11 @@ test('createGitHubRepository finds writable install when repo is past first page
},
],
});
- const result = await storageService.createGitHubRepository('vizably-new', {
+ const result = await storageService.createGitHubRepository('paged', {
githubUserClient: client,
});
assert.equal(result.needsInstall, false);
+ assert.equal(result.storageRef.full_name, 'sam/viz_paged');
});
test('createGitHubRepository finds writable install when installation is past first page', async () => {
@@ -463,13 +472,14 @@ test('createGitHubRepository finds writable install when installation is past fi
const installationProbe = Array.from({ length: 101 }, (_, i) => ({
id: i + 1,
contents: 'write',
- repos: i === 100 ? ['sam/vizably-new'] : [`sam/other-${i}`],
+ repos: i === 100 ? ['sam/viz_paged'] : [`sam/other-${i}`],
}));
const client = createMockGitHubClient({ installationProbe });
- const result = await storageService.createGitHubRepository('vizably-new', {
+ const result = await storageService.createGitHubRepository('paged', {
githubUserClient: client,
});
assert.equal(result.needsInstall, false);
+ assert.equal(result.storageRef.full_name, 'sam/viz_paged');
});
test('createGitHubRepository surfaces rate limits instead of needsInstall', async () => {
@@ -482,13 +492,13 @@ test('createGitHubRepository surfaces rate limits instead of needsInstall', asyn
};
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
- () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
+ () => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_RATE_LIMITED');
assert.equal(err.status, 429);
assert.match(err.message, /rate-limited/i);
assert.match(err.message, /do not reinstall/i);
- assert.equal(err.storageRef?.full_name, 'sam/vizably-new');
+ assert.equal(err.storageRef?.full_name, 'sam/viz_scans');
return true;
},
);
@@ -500,12 +510,12 @@ test('createGitHubRepository surfaces network failures instead of needsInstall',
probeErr.code = 'ENOTFOUND';
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
- () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
+ () => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_NETWORK_ERROR');
assert.equal(err.status, 503);
assert.match(err.message, /network/i);
- assert.equal(err.storageRef?.name, 'vizably-new');
+ assert.equal(err.storageRef?.name, 'viz_scans');
return true;
},
);
@@ -518,7 +528,7 @@ test('createGitHubRepository surfaces auth failures instead of needsInstall', as
probeErr.response = { data: { message: 'Bad credentials' }, headers: {} };
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
- () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
+ () => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_AUTH_FAILED');
assert.equal(err.status, 401);
@@ -535,7 +545,7 @@ test('createGitHubRepository surfaces GitHub outages instead of needsInstall', a
probeErr.response = { data: { message: 'Server Error' }, headers: {} };
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
- () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
+ () => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_UNAVAILABLE');
assert.equal(err.status, 503);
@@ -549,7 +559,7 @@ test('createGitHubRepository rejects invalid names', async () => {
const storageService = new StorageService();
const client = createMockGitHubClient();
await assert.rejects(
- () => storageService.createGitHubRepository('sam/vizably-new', { githubUserClient: client }),
+ () => storageService.createGitHubRepository('sam/scans', { githubUserClient: client }),
/name only/,
);
await assert.rejects(
@@ -562,22 +572,40 @@ test('createGitHubRepository rejects invalid names', async () => {
);
});
-test('createGitHubRepository normalizes whitespace before create', async () => {
+test('createGitHubRepository prefixes and normalizes whitespace before create', async () => {
const storageService = new StorageService();
const client = createMockGitHubClient({
installationProbe: [
{
id: 1,
contents: 'write',
- repos: ['sam/vizably-new'],
+ repos: ['sam/viz_accessibility-results'],
},
],
});
- const result = await storageService.createGitHubRepository(' vizably new ', {
+ const result = await storageService.createGitHubRepository(' accessibility results ', {
githubUserClient: client,
});
- assert.equal(result.storageRef.full_name, 'sam/vizably-new');
- assert.equal(result.storageRef.name, 'vizably-new');
+ assert.equal(result.storageRef.full_name, 'sam/viz_accessibility-results');
+ assert.equal(result.storageRef.name, 'viz_accessibility-results');
+});
+
+test('createGitHubRepository does not double-prefix an existing viz_ name', async () => {
+ const storageService = new StorageService();
+ const client = createMockGitHubClient({
+ installationProbe: [
+ {
+ id: 1,
+ contents: 'write',
+ repository_selection: 'all',
+ repos: [],
+ },
+ ],
+ });
+ const result = await storageService.createGitHubRepository('viz_reports', {
+ githubUserClient: client,
+ });
+ assert.equal(result.storageRef.name, 'viz_reports');
});
test('createGitHubRepository maps name-taken conflicts', async () => {
@@ -593,7 +621,7 @@ test('createGitHubRepository maps name-taken conflicts', async () => {
const client = createMockGitHubClient({ createRepoError: conflict });
await assert.rejects(
() => storageService.createGitHubRepository('taken', { githubUserClient: client }),
- /already exists/,
+ /viz_taken/,
);
});
@@ -1370,3 +1398,229 @@ test('deleteScanById stubs google until Phase 3', async () => {
(err) => err.status === 501 && err.code === 'PROVIDER_NOT_AVAILABLE',
);
});
+
+test('discoverAccountStores returns the session store first', async () => {
+ const storageService = new StorageService();
+ const client = createMockGitHubClient({
+ files: {
+ 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' },
+ 'scans/index.json': {
+ content: JSON.stringify({ schemaVersion: 1, scans: [] }),
+ sha: 'sha-index',
+ },
+ },
+ });
+ const result = await storageService.discoverAccountStores(
+ 'github',
+ { githubClient: client, githubUserClient: client },
+ { sessionStorageRef: STORAGE_REF },
+ );
+ assert.equal(result.source, 'session');
+ assert.equal(result.stores.length, 1);
+ assert.equal(result.stores[0].storageRef.full_name, STORAGE_REF.full_name);
+ assert.equal(result.stores[0].validation.status, 'loadable');
+});
+
+test('discoverAccountStores uses GET viz_scans before listing', async () => {
+ const storageService = new StorageService();
+ const client = createMockGitHubClient({
+ files: {
+ 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' },
+ 'scans/index.json': {
+ content: JSON.stringify({ schemaVersion: 1, scans: [] }),
+ sha: 'sha-index',
+ },
+ },
+ listedRepos: [],
+ repoGet: async ({ repo }) => {
+ if (repo !== 'viz_scans') {
+ const err = new Error('Not Found');
+ err.status = 404;
+ throw err;
+ }
+ return {
+ data: {
+ node_id: 'R_kgScans',
+ name: 'viz_scans',
+ full_name: 'sam/viz_scans',
+ html_url: 'https://github.com/sam/viz_scans',
+ default_branch: 'main',
+ permissions: { pull: true, push: true, admin: false },
+ },
+ };
+ },
+ });
+ const result = await storageService.discoverAccountStores('github', {
+ githubClient: client,
+ githubUserClient: client,
+ });
+ assert.equal(result.source, 'expected-name');
+ assert.equal(result.stores.length, 1);
+ assert.equal(result.stores[0].storageRef.full_name, 'sam/viz_scans');
+});
+
+test('discoverAccountStores lists repos when expected name is not a store', async () => {
+ const storageService = new StorageService();
+ const client = createMockGitHubClient({
+ files: {
+ 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' },
+ 'scans/index.json': {
+ content: JSON.stringify({ schemaVersion: 1, scans: [] }),
+ sha: 'sha-index',
+ },
+ },
+ listedRepos: [
+ {
+ node_id: STORAGE_REF.id,
+ full_name: STORAGE_REF.full_name,
+ private: true,
+ html_url: STORAGE_REF.html_url,
+ },
+ ],
+ repoGet: async ({ repo }) => {
+ if (repo === 'viz_scans') {
+ const err = new Error('Not Found');
+ err.status = 404;
+ throw err;
+ }
+ return {
+ data: {
+ default_branch: 'main',
+ permissions: { pull: true, push: true, admin: false },
+ name: repo,
+ full_name: `sam/${repo}`,
+ },
+ };
+ },
+ });
+ const result = await storageService.discoverAccountStores('github', {
+ githubClient: client,
+ githubUserClient: client,
+ });
+ assert.equal(result.source, 'list');
+ assert.equal(result.stores.length, 1);
+ assert.equal(result.stores[0].storageRef.id, STORAGE_REF.id);
+});
+
+test('discoverAccountStores ignores listed repos that have no manifest', async () => {
+ const storageService = new StorageService();
+ const client = createMockGitHubClient({
+ files: {},
+ listedRepos: [
+ {
+ node_id: STORAGE_REF.id,
+ full_name: STORAGE_REF.full_name,
+ private: true,
+ html_url: STORAGE_REF.html_url,
+ },
+ ],
+ repoGet: async ({ repo }) => {
+ if (repo === 'viz_scans') {
+ const err = new Error('Not Found');
+ err.status = 404;
+ throw err;
+ }
+ return {
+ data: {
+ default_branch: 'main',
+ permissions: { pull: true, push: true, admin: false },
+ name: repo,
+ full_name: `sam/${repo}`,
+ },
+ };
+ },
+ });
+ const result = await storageService.discoverAccountStores('github', {
+ githubClient: client,
+ githubUserClient: client,
+ });
+ assert.equal(result.source, 'list');
+ assert.equal(result.stores.length, 0);
+});
+
+test('discoverAccountStores returns every listed store with a manifest', async () => {
+ const storageService = new StorageService();
+ const client = createMockGitHubClient({
+ files: {
+ 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' },
+ 'scans/index.json': {
+ content: JSON.stringify({ schemaVersion: 1, scans: [] }),
+ sha: 'sha-index',
+ },
+ },
+ listedRepos: [
+ {
+ node_id: 'R_one',
+ full_name: 'sam/vizably-scans',
+ private: true,
+ html_url: 'https://github.com/sam/vizably-scans',
+ },
+ {
+ node_id: 'R_two',
+ full_name: 'sam/viz_scans-2',
+ private: true,
+ html_url: 'https://github.com/sam/viz_scans-2',
+ },
+ ],
+ repoGet: async ({ repo }) => {
+ if (repo === 'viz_scans') {
+ const err = new Error('Not Found');
+ err.status = 404;
+ throw err;
+ }
+ return {
+ data: {
+ default_branch: 'main',
+ permissions: { pull: true, push: true, admin: false },
+ name: repo,
+ full_name: `sam/${repo}`,
+ },
+ };
+ },
+ });
+ const result = await storageService.discoverAccountStores('github', {
+ githubClient: client,
+ githubUserClient: client,
+ });
+ assert.equal(result.source, 'list');
+ assert.equal(result.stores.length, 2);
+});
+
+test('createNextVizablyGitHubRepository falls back to viz_scans-2 when taken', async () => {
+ const storageService = new StorageService();
+ const created = [];
+ const client = createMockGitHubClient({
+ installationProbe: [
+ { id: 1, contents: 'write', repository_selection: 'all', repos: [] },
+ ],
+ createRepo: async ({ name, private: isPrivate }) => {
+ created.push(name);
+ if (name === 'viz_scans') {
+ const err = new Error('Repository creation failed.');
+ err.status = 422;
+ err.response = {
+ data: {
+ message: 'Repository creation failed.',
+ errors: [{ message: 'name already exists on this account' }],
+ },
+ };
+ throw err;
+ }
+ return {
+ data: {
+ node_id: 'R_kg2',
+ name,
+ full_name: `sam/${name}`,
+ private: isPrivate !== false,
+ html_url: `https://github.com/sam/${name}`,
+ },
+ };
+ },
+ });
+ const result = await storageService.createNextVizablyGitHubRepository({
+ githubUserClient: client,
+ });
+ assert.deepEqual(created, ['viz_scans', 'viz_scans-2']);
+ assert.equal(result.storageRef.name, 'viz_scans-2');
+ assert.equal(result.needsInstall, false);
+});
diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md
index 4e700c9..de70944 100644
--- a/docs/guides/auth_storage_guide/TODO.md
+++ b/docs/guides/auth_storage_guide/TODO.md
@@ -7,7 +7,8 @@
> Verify each item against the actual code before ticking it.
>
> **Model in one line:** the user's GitHub repo / Drive folder *is* the account.
-> Flow is **browse → select → validate (fit-check) → load or init**. No project DB.
+> Flow is **sign in → discover or create → validate (fit-check) → load or init**.
+> No project DB. GitHub Connect is one action (no name field / repo picker).
---
@@ -156,17 +157,21 @@ Provider-neutral API shape; **GitHub picker wired**, Google deferred to Phase 3.
- [x] Keep `runScan`, `getScanResults`, `getProblem`
- [x] `deleteScan(id)` → `DELETE /api/scans/:id`
-### ConnectView (`frontend/src/views/ConnectView.jsx`) — the picker
+### ConnectView (`frontend/src/views/ConnectView.jsx`)
-- [x] GitHub: list repos via `listStorages('github')`
+GitHub Connect is one action. Vizably discovers or creates the store; the user
+does not name or pick a repository in the normal flow.
+
+- [x] Discover stores via `GET /api/auth/storage/discover` (session ref →
+ `viz_scans` GET → list + `vizably.json`)
+- [x] No repository name field; no "Use an existing repository" picker
+- [x] Zero stores → one button creates `viz_scans` / `viz_scans-2` / … and inits
+- [x] One store → load or init automatically selected
+- [x] Two or more stores → chooser for that ambiguity only
- [x] Google Picker deferred to Phase 3 (hide or disable Google connect path)
-- [x] Persistent **"Create new"** option (the `init` path on a fresh store)
-- [x] On select → `validateStorage` → render fit-check status + scan count
-- [x] Action button follows status: `loadable`→"Load my account",
- `initializable`/new→"Set up & continue", `incompatible`/`invalid`→blocked + guidance
+- [x] Action button follows fit-check status
- [x] Disable init when `capabilities.canWrite === false`
- [x] On confirm → `setupStorage(provider, storageRef, action)` → dashboard
-- [x] Replace hard-coded `existing` lists in `frontend/src/data/placeholders.js`
- [x] `ConnectView` — accept a `storageError` prop for failures
### App Routes (`frontend/src/App.jsx`)
diff --git a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md
index 7b07da6..6431aa4 100644
--- a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md
+++ b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md
@@ -15,10 +15,12 @@ account travels with the user's repo/folder, they can sign in from any device,
server-side data, no lock-in, no per-user hosting cost. That is what makes it
cheap to offer to as many people as possible.
-The defining UX is **browse → select → validate → load-or-init**:
+The defining UX is **sign in → discover or create → load-or-init**:
1. The user connects GitHub or Google (OAuth).
-2. They **see the repos/folders they already have** and **select one**.
+2. Vizably **discovers** an existing account store (`vizably.json`) or
+ **creates** the next default location (`viz_scans`, then `viz_scans-2`, …).
+ A chooser appears only when two or more stores are found.
3. vizably **validates** whether that storage *fits the data needed to load
the account back* (a "fit-check").
4. Depending on the result, vizably **loads** the existing account or
@@ -51,10 +53,10 @@ Provider redirects to /api/auth/{provider}/callback with auth code
Backend exchanges code for tokens → encrypts tokens → stores in session
│
▼
-User lands on ConnectView — the storage picker
+User lands on ConnectView — one Connect action
│
- ├─ GitHub: backend lists the user's repos → user selects one
- └─ Google: Google Picker lists Drive folders → user selects one
+ ├─ GitHub: discover vizably.json (or create viz_scans / viz_scans-2 / …)
+ └─ Google: same resolution model via Picker (Phase 3)
│
▼
Backend VALIDATES the selected storage (fit-check)
@@ -81,9 +83,11 @@ scans/index scans/ dir (init or cancel)
On scan: results appended to the user's storage (atomic write)
```
-The user can also choose **"Create a new repo/folder"** instead of selecting an
-existing one — that is just the `initializable` path against a freshly created
-store.
+Connect does not ask the user to name or pick a repository. Vizably discovers
+an existing account store by looking for `vizably.json`, or creates `viz_scans`
+(then `viz_scans-2`, `viz_scans-3`, … if that name is taken). A chooser appears
+only when discovery finds two or more stores. GitHub's own permissions remain
+the account ACL — Vizably does not add an extra ownership check.
### Identity model — read this first
@@ -364,20 +368,20 @@ Session cookies, not Bearer tokens. All calls use `credentials: 'include'`; no
For Google, selection is done with the **Google Picker** client library; the
chosen folder id flows into `validateStorage` / `setupStorage`.
-### ConnectView — the picker (`frontend/src/views/ConnectView.jsx`)
+### ConnectView (`frontend/src/views/ConnectView.jsx`)
-Today this is a placeholder with hard-coded `existing` lists in
-`frontend/src/data/placeholders.js`. The real ConnectView:
+Connect is one action. Vizably discovers an existing account store by looking
+for `vizably.json` (never by matching the repo name), or creates `viz_scans`
+(`viz_scans-2`, … if taken). There is no name field and no repository picker
+in the normal flow. A chooser appears only when discovery finds two or more
+stores.
-1. **Lists real storage.** GitHub → `listStorages('github')`. Google → launch
- Google Picker. Plus a persistent **"Create new"** option.
-2. **Validates on select.** On pick, call `validateStorage` and render the
- fit-check result (status copy + scan count when `loadable`).
-3. **Offers the right action.** Button text follows status:
- `loadable` → "Load my account", `initializable`/new → "Set up & continue",
- `incompatible`/`invalid` → blocked with guidance.
-4. **Confirms.** `setupStorage(provider, storageRef, action)` → on success the
- account (with `storage`) is attached; navigate to the dashboard.
+1. **Discover.** `discoverStorages('github')` — session `storageRef`, then GET
+ `viz_scans`, then list repos as a fallback.
+2. **Zero stores.** One button creates the next default name and inits it.
+3. **One store.** Fit-check copy + "Load my account" / "Set up & continue".
+4. **Two or more.** Chooser of discovered stores, then the same confirm action.
+5. **Confirms.** `setupStorage(provider, storageRef, action)` → dashboard.
### App routes (`frontend/src/App.jsx`)
@@ -536,14 +540,14 @@ repo without asking the user to leave and create one manually.
### GitHub (Octokit)
```js
-// List repos the user can write (for the picker)
+// List repos (discovery fallback when session ref and viz_scans GET miss)
await octokit.repos.listForAuthenticatedUser({ visibility: 'all', per_page: 100 });
// Existence / fit-check read — get the manifest blob
await octokit.repos.getContent({ owner, repo, path: 'vizably.json' }); // 404 ⇒ no manifest
-// Create a repo for the "new" path
-await octokit.repos.createForAuthenticatedUser({ name, private: true });
+// Create a repo for the "new" path — name is always `viz_`
+await octokit.repos.createForAuthenticatedUser({ name: 'viz_scans', private: true });
// Atomic-ish write (pass sha to update; omit to create)
await octokit.repos.createOrUpdateFileContents({ owner, repo, path, message, content, branch, sha });
diff --git a/frontend/src/__tests__/apiClient.test.js b/frontend/src/__tests__/apiClient.test.js
index 7a05ebf..1fd991c 100644
--- a/frontend/src/__tests__/apiClient.test.js
+++ b/frontend/src/__tests__/apiClient.test.js
@@ -99,6 +99,33 @@ describe('ApiClient', () => {
)
})
+ it('discoverStorages calls GET /api/auth/storage/discover', async () => {
+ const fetchImpl = mockFetch({ provider: 'github', stores: [], source: 'list' })
+ const client = new ApiClient({ fetchImpl })
+
+ await client.discoverStorages('github')
+
+ expect(fetchImpl).toHaveBeenCalledWith(
+ '/api/auth/storage/discover?provider=github',
+ expect.any(Object),
+ )
+ })
+
+ it('createStorage omits name for the default store', async () => {
+ const fetchImpl = mockFetch({ provider: 'github', storageRef: { name: 'viz_scans' } })
+ const client = new ApiClient({ fetchImpl })
+
+ await client.createStorage()
+
+ expect(fetchImpl).toHaveBeenCalledWith(
+ '/api/auth/storage/create',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ provider: 'github' }),
+ }),
+ )
+ })
+
it('validateStorage posts provider and storageRef', async () => {
const fetchImpl = mockFetch({ status: 'loadable' })
const client = new ApiClient({ fetchImpl })
diff --git a/frontend/src/__tests__/connectView.test.jsx b/frontend/src/__tests__/connectView.test.jsx
index 3d91315..5ebf852 100644
--- a/frontend/src/__tests__/connectView.test.jsx
+++ b/frontend/src/__tests__/connectView.test.jsx
@@ -2,40 +2,40 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ConnectView from '../views/ConnectView'
-const REPO = {
- id: 'R_kg',
- name: 'site-audits',
- full_name: 'sam/site-audits',
- private: true,
- html_url: 'https://github.com/sam/site-audits',
+const LOADABLE = {
+ status: 'loadable',
+ reason: null,
+ capabilities: { canRead: true, canWrite: true, canCreate: false },
+ manifestSummary: { scanCount: 3, schemaVersion: 1, accountId: 'a1' },
+}
+
+const STORE = {
+ storageRef: {
+ id: 'R_kg',
+ name: 'vizably-scans',
+ full_name: 'sam/vizably-scans',
+ html_url: 'https://github.com/sam/vizably-scans',
+ },
+ validation: LOADABLE,
}
function mockClient(overrides = {}) {
return {
- listStorages: vi.fn().mockResolvedValue({ provider: 'github', storages: [REPO] }),
- validateStorage: vi.fn().mockResolvedValue({
- status: 'loadable',
- reason: null,
- capabilities: { canRead: true, canWrite: true, canCreate: false },
- manifestSummary: { scanCount: 3, schemaVersion: 1, accountId: 'a1' },
+ discoverStorages: vi.fn().mockResolvedValue({
+ provider: 'github',
+ stores: [STORE],
+ source: 'list',
}),
+ validateStorage: vi.fn().mockResolvedValue(LOADABLE),
setupStorage: vi.fn().mockResolvedValue({ success: true }),
- checkRepoNameAvailability: vi.fn().mockImplementation(async (name) => ({
- provider: 'github',
- name,
- normalizedName: name.trim(),
- full_name: `sam/${name.trim()}`,
- status: 'available',
- message: `sam/${name.trim()} is available.`,
- })),
createStorage: vi.fn().mockResolvedValue({
provider: 'github',
storageRef: {
id: 'R_kgNew',
- name: 'vizably-new',
- full_name: 'sam/vizably-new',
+ name: 'viz_scans',
+ full_name: 'sam/viz_scans',
private: true,
- html_url: 'https://github.com/sam/vizably-new',
+ html_url: 'https://github.com/sam/viz_scans',
},
needsInstall: false,
installUrl: null,
@@ -44,19 +44,6 @@ function mockClient(overrides = {}) {
}
}
-async function typeNewRepoName(value) {
- fireEvent.click(screen.getByText(/Create a new repository/i))
- const input = screen.getByDisplayValue('vizably-scans')
- fireEvent.change(input, { target: { value } })
- return input
-}
-
-async function waitForRepoPicker(client) {
- await screen.findByText('sam/site-audits (private)', {}, { timeout: 3000 })
- await waitFor(() => expect(client.listStorages).toHaveBeenCalledWith('github'))
- await waitFor(() => expect(client.validateStorage).toHaveBeenCalled())
-}
-
describe('ConnectView', () => {
beforeEach(() => {
vi.stubGlobal('location', { href: 'http://localhost:5173/connect' })
@@ -66,74 +53,86 @@ describe('ConnectView', () => {
vi.unstubAllGlobals()
})
- it('loads GitHub repos and validates the selected repo', async () => {
+ it('discovers a store and offers load without a name field or repo picker', async () => {
const client = mockClient()
render(
- ,
+ ,
)
- await waitForRepoPicker(client)
- expect(client.validateStorage).toHaveBeenCalledWith('github', {
- id: REPO.id,
- full_name: REPO.full_name,
- html_url: REPO.html_url,
- })
- expect(screen.getByText('Vizably account found')).toBeInTheDocument()
+ await waitFor(() => expect(client.discoverStorages).toHaveBeenCalledWith('github'))
+ expect(await screen.findByText('Vizably account found')).toBeInTheDocument()
expect(screen.getByText('3 saved scans')).toBeInTheDocument()
+ expect(screen.queryByPlaceholderText('scans')).not.toBeInTheDocument()
+ expect(screen.queryByText(/Use an existing/i)).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /load my account/i })).toBeInTheDocument()
})
- it('calls setupStorage with load on confirm for loadable storage', async () => {
+ it('calls setupStorage with load on confirm', async () => {
const onDone = vi.fn()
const client = mockClient()
render(
,
)
- await waitForRepoPicker(client)
expect(await screen.findByText('Vizably account found')).toBeInTheDocument()
-
- const button = screen.getByRole('button', { name: /load my account/i })
- fireEvent.click(button)
+ fireEvent.click(screen.getByRole('button', { name: /load my account/i }))
await waitFor(() =>
expect(client.setupStorage).toHaveBeenCalledWith(
'github',
- expect.objectContaining({ id: REPO.id, full_name: REPO.full_name }),
+ expect.objectContaining({ id: STORE.storageRef.id, full_name: STORE.storageRef.full_name }),
'load',
),
)
expect(onDone).toHaveBeenCalled()
})
- it('offers set up & continue for initializable storage', async () => {
+ it('creates viz_scans and inits when no store exists', async () => {
+ const onDone = vi.fn()
const client = mockClient({
- validateStorage: vi.fn().mockResolvedValue({
- status: 'initializable',
- capabilities: { canRead: true, canWrite: true, canCreate: true },
+ discoverStorages: vi.fn().mockResolvedValue({
+ provider: 'github',
+ stores: [],
+ source: 'list',
}),
})
render(
- ,
+ ,
)
- await waitForRepoPicker(client)
- expect(await screen.findByText('Ready to set up')).toBeInTheDocument()
- expect(
- screen.getByRole('button', { name: /set up & continue/i }),
- ).toBeInTheDocument()
+ expect(await screen.findByRole('button', { name: /set up vizably storage/i })).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /set up vizably storage/i }))
+
+ await waitFor(() => expect(client.createStorage).toHaveBeenCalledWith())
+ await waitFor(() =>
+ expect(client.setupStorage).toHaveBeenCalledWith(
+ 'github',
+ expect.objectContaining({ id: 'R_kgNew', full_name: 'sam/viz_scans' }),
+ 'init',
+ ),
+ )
+ expect(onDone).toHaveBeenCalled()
})
- it('blocks init when storage is not writable', async () => {
+ it('shows a chooser only when two stores are discovered', async () => {
+ const second = {
+ storageRef: {
+ id: 'R_two',
+ name: 'viz_scans',
+ full_name: 'sam/viz_scans',
+ html_url: 'https://github.com/sam/viz_scans',
+ },
+ validation: {
+ ...LOADABLE,
+ manifestSummary: { scanCount: 1, schemaVersion: 1, accountId: 'a2' },
+ },
+ }
const client = mockClient({
- validateStorage: vi.fn().mockResolvedValue({
- status: 'initializable',
- capabilities: { canRead: true, canWrite: false, canCreate: false },
+ discoverStorages: vi.fn().mockResolvedValue({
+ provider: 'github',
+ stores: [STORE, second],
+ source: 'list',
}),
})
@@ -141,20 +140,24 @@ describe('ConnectView', () => {
,
)
- await waitForRepoPicker(client)
- expect(await screen.findByText('Ready to set up')).toBeInTheDocument()
-
- const button = screen.getByRole('button', { name: /set up & continue/i })
- expect(button).toBeDisabled()
- expect(screen.getByText(/read-only/i)).toBeInTheDocument()
+ expect(await screen.findByLabelText(/choose vizably storage/i)).toBeInTheDocument()
+ expect(screen.getByText(/more than one vizably store/i)).toBeInTheDocument()
})
- it('blocks incompatible storage', async () => {
+ it('blocks init when storage is not writable', async () => {
const client = mockClient({
- validateStorage: vi.fn().mockResolvedValue({
- status: 'incompatible',
- reason: 'too_new',
- capabilities: { canRead: true, canWrite: true, canCreate: false },
+ discoverStorages: vi.fn().mockResolvedValue({
+ provider: 'github',
+ stores: [
+ {
+ storageRef: STORE.storageRef,
+ validation: {
+ status: 'initializable',
+ capabilities: { canRead: true, canWrite: false, canCreate: false },
+ },
+ },
+ ],
+ source: 'list',
}),
})
@@ -162,11 +165,9 @@ describe('ConnectView', () => {
,
)
- await waitForRepoPicker(client)
- expect(await screen.findByText('Update Vizably required')).toBeInTheDocument()
-
- const button = screen.getByRole('button', { name: /continue/i })
- expect(button).toBeDisabled()
+ expect(await screen.findByText('Ready to set up')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /set up & continue/i })).toBeDisabled()
+ expect(screen.getByText(/read-only/i)).toBeInTheDocument()
})
it('shows Google deferred message for google provider', () => {
@@ -179,7 +180,7 @@ describe('ConnectView', () => {
})
it('renders storageError prop', async () => {
- const client = mockClient({ listStorages: vi.fn().mockRejectedValue(new Error('nope')) })
+ const client = mockClient()
render(
{
)
expect(screen.getByRole('alert')).toHaveTextContent('GitHub sign-in failed')
+ await waitFor(() => expect(client.discoverStorages).toHaveBeenCalled())
})
- it('creates a new repository then validates for init', async () => {
- const created = {
- id: 'R_kgNew',
- name: 'vizably-new',
- full_name: 'sam/vizably-new',
- private: true,
- html_url: 'https://github.com/sam/vizably-new',
- }
- const client = mockClient({
- createStorage: vi.fn().mockResolvedValue({
- provider: 'github',
- storageRef: created,
- needsInstall: false,
- installUrl: null,
- }),
- validateStorage: vi.fn().mockResolvedValue({
- status: 'initializable',
- capabilities: { canRead: true, canWrite: true, canCreate: true },
- }),
- })
-
- render(
- ,
- )
-
- await waitForRepoPicker(client)
-
- await typeNewRepoName('vizably-new')
- expect(await screen.findByText(/is available/i)).toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('button', { name: /create repository/i }))
-
- await waitFor(() => expect(client.createStorage).toHaveBeenCalledWith('vizably-new'))
- await waitFor(() =>
- expect(client.validateStorage).toHaveBeenCalledWith(
- 'github',
- expect.objectContaining({ id: 'R_kgNew', full_name: 'sam/vizably-new' }),
- ),
- )
- expect(await screen.findByText('Ready to set up')).toBeInTheDocument()
- })
-
- it('shows probe failure message without install CTA', async () => {
- const err = new Error(
- 'GitHub rate-limited the request while checking App installation access. Wait a moment and refresh — do not reinstall the Vizably GitHub App.',
- )
- err.code = 'GITHUB_RATE_LIMITED'
- err.storageRef = {
- id: 'R_kgNew',
- name: 'vizably-new',
- full_name: 'sam/vizably-new',
- private: true,
- html_url: 'https://github.com/sam/vizably-new',
- }
- const client = mockClient({
- createStorage: vi.fn().mockRejectedValue(err),
- })
-
- render(
- ,
- )
-
- await waitForRepoPicker(client)
- fireEvent.click(screen.getByText(/Create a new repository/i))
- fireEvent.change(screen.getByDisplayValue('vizably-scans'), {
- target: { value: 'vizably-new' },
- })
- // Availability must resolve so Create is enabled.
- expect(await screen.findByText(/is available/i)).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /create repository/i }))
-
- expect(await screen.findByRole('alert')).toHaveTextContent(/do not reinstall/i)
- expect(screen.queryByText(/Open GitHub App install/i)).not.toBeInTheDocument()
- })
-
- it('normalizes whitespace in the repository name before create', async () => {
- const client = mockClient({
- createStorage: vi.fn().mockResolvedValue({
- provider: 'github',
- storageRef: {
- id: 'R_kgNew',
- name: 'vizably-new',
- full_name: 'sam/vizably-new',
- private: true,
- html_url: 'https://github.com/sam/vizably-new',
- },
- needsInstall: false,
- installUrl: null,
- }),
- validateStorage: vi.fn().mockResolvedValue({
- status: 'initializable',
- capabilities: { canRead: true, canWrite: true, canCreate: true },
- }),
- checkRepoNameAvailability: vi.fn().mockResolvedValue({
- provider: 'github',
- name: 'vizably-new',
- normalizedName: 'vizably-new',
- full_name: 'sam/vizably-new',
- status: 'available',
- message: 'sam/vizably-new is available.',
- }),
- })
-
- render(
- ,
- )
-
- await waitForRepoPicker(client)
- fireEvent.click(screen.getByText(/Create a new repository/i))
- fireEvent.change(screen.getByDisplayValue('vizably-scans'), {
- target: { value: ' vizably new ' },
- })
- expect(await screen.findByText(/is available/i)).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /create repository/i }))
-
- await waitFor(() => expect(client.createStorage).toHaveBeenCalledWith('vizably-new'))
- expect(screen.getByDisplayValue('vizably-new')).toBeInTheDocument()
- })
-
- it('keeps focus on the repository name input while typing', async () => {
- const client = mockClient()
- render(
- ,
- )
-
- await waitForRepoPicker(client)
- fireEvent.click(screen.getByText(/Create a new repository/i))
-
- const input = screen.getByDisplayValue('vizably-scans')
- input.focus()
- expect(document.activeElement).toBe(input)
-
- fireEvent.change(input, { target: { value: 'v' } })
- expect(document.activeElement).toBe(input)
- fireEvent.change(input, { target: { value: 'vi' } })
- expect(document.activeElement).toBe(input)
- fireEvent.change(input, { target: { value: 'viz' } })
- expect(document.activeElement).toBe(input)
- expect(input).toHaveValue('viz')
- })
-
- it('shows taken status and blocks create for an existing name', async () => {
+ it('shows install hop when create returns needsInstall', async () => {
const client = mockClient({
- // Name is taken on GitHub but not in the local picker list.
- checkRepoNameAvailability: vi.fn().mockResolvedValue({
+ discoverStorages: vi.fn().mockResolvedValue({
provider: 'github',
- name: 'already-taken',
- normalizedName: 'already-taken',
- full_name: 'sam/already-taken',
- status: 'taken',
- message: 'A repository named "already-taken" already exists on your account.',
+ stores: [],
+ source: 'list',
}),
- })
-
- render(
- ,
- )
-
- await waitForRepoPicker(client)
- await typeNewRepoName('already-taken')
-
- expect(await screen.findByText(/already exists/i)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /create repository/i })).toBeDisabled()
- expect(client.createStorage).not.toHaveBeenCalled()
- })
-
- it('shows install hop when create returns needsInstall', async () => {
- const client = mockClient({
createStorage: vi.fn().mockResolvedValue({
provider: 'github',
storageRef: {
id: 'R_kgNew',
- name: 'vizably-new',
- full_name: 'sam/vizably-new',
+ name: 'viz_scans',
+ full_name: 'sam/viz_scans',
private: true,
- html_url: 'https://github.com/sam/vizably-new',
+ html_url: 'https://github.com/sam/viz_scans',
},
needsInstall: true,
installUrl: 'https://github.com/apps/vizably/installations/new',
@@ -379,15 +220,13 @@ describe('ConnectView', () => {
,
)
- await waitForRepoPicker(client)
- await typeNewRepoName('vizably-new')
- expect(await screen.findByText(/is available/i)).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /create repository/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /set up vizably storage/i }))
expect(await screen.findByText(/Open GitHub App install/i)).toHaveAttribute(
'href',
'https://github.com/apps/vizably/installations/new',
)
expect(screen.getByText(/I've added it — refresh/i)).toBeInTheDocument()
+ expect(client.createStorage).toHaveBeenCalledWith()
})
})
diff --git a/frontend/src/__tests__/githubRepoName.test.js b/frontend/src/__tests__/githubRepoName.test.js
index d99ce4a..b1fe614 100644
--- a/frontend/src/__tests__/githubRepoName.test.js
+++ b/frontend/src/__tests__/githubRepoName.test.js
@@ -1,5 +1,11 @@
-import { describe, it, expect } from 'vitest'
-import { normalizeGitHubRepoName } from '../utils/githubRepoName'
+import { describe, expect, it } from 'vitest'
+import {
+ applyVizablyRepoPrefix,
+ normalizeGitHubRepoName,
+ nextVizablyStoreName,
+ VIZABLY_DEFAULT_STORE_NAME,
+ VIZABLY_REPO_PREFIX,
+} from '../utils/githubRepoName'
describe('normalizeGitHubRepoName', () => {
it('trims leading and trailing whitespace', () => {
@@ -19,3 +25,26 @@ describe('normalizeGitHubRepoName', () => {
expect(normalizeGitHubRepoName(' \t ')).toBe('')
})
})
+
+describe('applyVizablyRepoPrefix', () => {
+ it('prepends viz_ after normalizing', () => {
+ expect(applyVizablyRepoPrefix('scans')).toBe('viz_scans')
+ expect(applyVizablyRepoPrefix(' accessibility results ')).toBe(
+ 'viz_accessibility-results',
+ )
+ expect(applyVizablyRepoPrefix('reports')).toBe(`${VIZABLY_REPO_PREFIX}reports`)
+ })
+
+ it('does not double-prefix when viz_ is already present', () => {
+ expect(applyVizablyRepoPrefix('viz_scans')).toBe('viz_scans')
+ expect(applyVizablyRepoPrefix('VIZ_reports')).toBe('viz_reports')
+ })
+})
+
+describe('nextVizablyStoreName', () => {
+ it('uses viz_scans then numbered fallbacks', () => {
+ expect(nextVizablyStoreName([])).toBe(VIZABLY_DEFAULT_STORE_NAME)
+ expect(nextVizablyStoreName(['viz_scans'])).toBe('viz_scans-2')
+ expect(nextVizablyStoreName(['viz_scans', 'viz_scans-2'])).toBe('viz_scans-3')
+ })
+})
diff --git a/frontend/src/data/placeholders.js b/frontend/src/data/placeholders.js
index 76caa6f..0b2dc65 100644
--- a/frontend/src/data/placeholders.js
+++ b/frontend/src/data/placeholders.js
@@ -9,7 +9,7 @@ export const PROVIDERS = {
name: 'GitHub',
store: 'a private GitHub repo',
storeShort: 'GitHub repo',
- dest: 'vizably-scans',
+ dest: 'scans',
destIcon: 'GitBranch',
unit: 'repository',
unitShort: 'repo',
diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js
index ba8756e..3bb4634 100644
--- a/frontend/src/lib/apiClient.js
+++ b/frontend/src/lib/apiClient.js
@@ -105,6 +105,21 @@ export class ApiClient {
)
}
+ /**
+ * Discover existing Vizably account stores (manifest-based).
+ * @param {'github' | 'google'} provider
+ * @returns {Promise<{
+ * provider: string,
+ * stores: Array<{ storageRef: object, validation: object }>,
+ * source: string | null,
+ * }>}
+ */
+ discoverStorages(provider) {
+ return this._request(
+ `/api/auth/storage/discover?provider=${encodeURIComponent(provider)}`,
+ )
+ }
+
/**
* Check whether a GitHub repository name is available for the signed-in user.
* @param {string} name
@@ -125,7 +140,8 @@ export class ApiClient {
/**
* Create a private empty GitHub repository for storage onboarding.
- * @param {string} name repository name (not owner/name)
+ * Omit `name` to use the next default (`viz_scans`, then `viz_scans-2`, …).
+ * @param {string} [name] repository name (not owner/name)
* @returns {Promise<{
* provider: string,
* storageRef: object,
@@ -137,7 +153,9 @@ export class ApiClient {
return this._request('/api/auth/storage/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ provider: 'github', name }),
+ body: JSON.stringify(
+ name ? { provider: 'github', name } : { provider: 'github' },
+ ),
})
}
diff --git a/frontend/src/utils/githubRepoName.js b/frontend/src/utils/githubRepoName.js
index 4474253..629f485 100644
--- a/frontend/src/utils/githubRepoName.js
+++ b/frontend/src/utils/githubRepoName.js
@@ -12,3 +12,49 @@ export function normalizeGitHubRepoName(name) {
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
}
+
+/** Prefix applied to every repository Vizably creates. */
+export const VIZABLY_REPO_PREFIX = 'viz_'
+
+/** First-choice store name for a new account (`viz_scans`, then `viz_scans-2`, …). */
+export const VIZABLY_DEFAULT_STORE_NAME = `${VIZABLY_REPO_PREFIX}scans`
+
+/**
+ * Normalize then ensure the Vizably create-path prefix.
+ * Idempotent: names that already start with `viz_` (any casing) keep a single prefix.
+ *
+ * @param {unknown} name
+ * @returns {string}
+ */
+export function applyVizablyRepoPrefix(name) {
+ const normalized = normalizeGitHubRepoName(name)
+ if (!normalized) {
+ return ''
+ }
+ if (normalized.toLowerCase().startsWith(VIZABLY_REPO_PREFIX)) {
+ return `${VIZABLY_REPO_PREFIX}${normalized.slice(VIZABLY_REPO_PREFIX.length)}`
+ }
+ return `${VIZABLY_REPO_PREFIX}${normalized}`
+}
+
+/**
+ * Next unused create-path name. Tries `viz_scans`, then `viz_scans-2`, …
+ *
+ * @param {Iterable} [takenNames]
+ * @returns {string}
+ */
+export function nextVizablyStoreName(takenNames = []) {
+ const taken = new Set(
+ [...takenNames].map((name) => String(name ?? '').toLowerCase()).filter(Boolean),
+ )
+ if (!taken.has(VIZABLY_DEFAULT_STORE_NAME)) {
+ return VIZABLY_DEFAULT_STORE_NAME
+ }
+ for (let n = 2; n <= 999; n += 1) {
+ const candidate = `${VIZABLY_DEFAULT_STORE_NAME}-${n}`
+ if (!taken.has(candidate)) {
+ return candidate
+ }
+ }
+ throw new Error('Could not find an available Vizably repository name')
+}
diff --git a/frontend/src/views/ConnectView.jsx b/frontend/src/views/ConnectView.jsx
index 1e74e5f..63a5e81 100644
--- a/frontend/src/views/ConnectView.jsx
+++ b/frontend/src/views/ConnectView.jsx
@@ -3,7 +3,7 @@ import { Button, Card } from '../design-system'
import { Ico, GoogleMark } from '../lib/icons'
import { apiClient } from '../lib/apiClient'
import { PROVIDERS } from '../data/placeholders'
-import { normalizeGitHubRepoName } from '../utils/githubRepoName'
+import { VIZABLY_DEFAULT_STORE_NAME } from '../utils/githubRepoName'
const STATUS_UI = {
loadable: {
@@ -62,98 +62,18 @@ function reasonMessage(reason) {
}
}
-function storageRefFromRepo(repo) {
+function storageRefFromHit(hit) {
+ const ref = hit.storageRef || hit
return {
- id: repo.id,
- full_name: repo.full_name,
- html_url: repo.html_url,
+ id: ref.id,
+ full_name: ref.full_name,
+ html_url: ref.html_url,
+ name: ref.name,
}
}
-function findRepoByName(storages, name) {
- const trimmed = name.trim()
- if (!trimmed) return null
- return (
- storages.find((r) => r.name === trimmed) ||
- storages.find((r) => r.full_name === trimmed) ||
- storages.find((r) => r.full_name.endsWith(`/${trimmed}`)) ||
- null
- )
-}
-
-const NAME_CHECK_DEBOUNCE_MS = 400
-
-/**
- * Stable option card — must live outside ConnectView so typing into nested
- * inputs does not remount this tree (and steal focus) on every keystroke.
- */
-function ConnectOption({ active, onSelect, icon, title, desc, children }) {
- return (
-
-
-
- {active && (
-
- )}
-
-
-
-
- {Ico(icon, 17, 'currentColor')}
-
-
- {title}
-
-
-
- {desc}
-
- {active && children &&
{children}
}
-
-
-
- )
-}
-
/**
- * Connect storage — pick a GitHub repo, run fit-check, load or init account.
+ * Connect storage — discover or create the Vizably store, then load or init.
*
* @param {object} props
* @param {'github' | 'google'} props.provider
@@ -172,228 +92,129 @@ export default function ConnectView({
const pv = PROVIDERS[provider] || PROVIDERS.github
const isGitHub = provider === 'github'
- const [mode, setMode] = useState('existing')
- const [newRepoName, setNewRepoName] = useState(pv.dest)
- const [storages, setStorages] = useState([])
+ const [stores, setStores] = useState([])
const [selectedId, setSelectedId] = useState('')
- const [createdStorageRef, setCreatedStorageRef] = useState(null)
+ const [createdRef, setCreatedRef] = useState(null)
+ const [validation, setValidation] = useState(null)
const [needsInstall, setNeedsInstall] = useState(false)
const [installUrl, setInstallUrl] = useState(null)
- const [validation, setValidation] = useState(null)
- const [loadingRepos, setLoadingRepos] = useState(false)
+ const [discovering, setDiscovering] = useState(false)
const [creating, setCreating] = useState(false)
const [validating, setValidating] = useState(false)
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState(storageError)
- const [listError, setListError] = useState(null)
- const [nameAvailability, setNameAvailability] = useState(null)
- const [checkingName, setCheckingName] = useState(false)
- const selectedRepo = useMemo(
- () => storages.find((r) => r.id === selectedId) ?? null,
- [storages, selectedId],
+ const selectedHit = useMemo(
+ () => stores.find((hit) => hit.storageRef.id === selectedId) ?? null,
+ [stores, selectedId],
)
- const activeStorageRef = useMemo(() => {
- if (!isGitHub) return null
- if (mode === 'existing') {
- return selectedRepo ? storageRefFromRepo(selectedRepo) : null
- }
- if (createdStorageRef) {
- return {
- id: createdStorageRef.id,
- full_name: createdStorageRef.full_name,
- html_url: createdStorageRef.html_url,
+ const activeStorageRef = createdRef || (selectedHit ? storageRefFromHit(selectedHit) : null)
+ const statusUi = validation ? STATUS_UI[validation.status] : null
+ const proposedAction = statusUi?.action ?? null
+ const canWrite = validation?.capabilities?.canWrite !== false
+ const initBlocked = proposedAction === 'init' && validation && !canWrite
+ const emptyAccount = stores.length === 0 && !createdRef
+ const ambiguous = stores.length > 1 && !createdRef
+
+ const confirmBlocked =
+ needsInstall ||
+ (!emptyAccount &&
+ (!validation ||
+ !proposedAction ||
+ validation.status === 'incompatible' ||
+ validation.status === 'invalid' ||
+ initBlocked ||
+ !activeStorageRef))
+
+ const confirmLabel = useMemo(() => {
+ if (emptyAccount) return 'Set up Vizably storage'
+ if (statusUi?.button) return statusUi.button
+ return 'Continue'
+ }, [emptyAccount, statusUi])
+
+ const runValidation = useCallback(
+ async (storageRef) => {
+ if (!storageRef) {
+ setValidation(null)
+ return
}
- }
- const match = findRepoByName(storages, newRepoName)
- return match ? storageRefFromRepo(match) : null
- }, [isGitHub, mode, selectedRepo, storages, newRepoName, createdStorageRef])
+ setValidating(true)
+ setError(null)
+ try {
+ const result = await client.validateStorage('github', storageRef)
+ setValidation(result)
+ if (result?.capabilities?.canWrite) {
+ setNeedsInstall(false)
+ }
+ } catch (err) {
+ setValidation(null)
+ setError(err.message || 'Failed to validate storage')
+ } finally {
+ setValidating(false)
+ }
+ },
+ [client],
+ )
- const loadStorages = useCallback(async () => {
- if (!isGitHub) return
- setLoadingRepos(true)
- setListError(null)
+ const loadDiscovery = useCallback(async () => {
+ if (!isGitHub) return []
+ setDiscovering(true)
+ setError(null)
try {
- const result = await client.listStorages('github')
- const list = result.storages ?? []
- setStorages(list)
- setSelectedId((prev) => prev || list[0]?.id || '')
+ const result = await client.discoverStorages('github')
+ const list = result.stores ?? []
+ setStores(list)
+ setSelectedId((prev) => {
+ if (prev && list.some((hit) => hit.storageRef.id === prev)) return prev
+ return list[0]?.storageRef.id || ''
+ })
return list
} catch (err) {
- setListError(err.message || 'Failed to load repositories')
- setStorages([])
+ setError(err.message || 'Failed to look up Vizably storage')
+ setStores([])
setSelectedId('')
return []
} finally {
- setLoadingRepos(false)
+ setDiscovering(false)
}
}, [client, isGitHub])
useEffect(() => {
- loadStorages()
- }, [loadStorages])
+ loadDiscovery()
+ }, [loadDiscovery])
useEffect(() => {
setError(storageError)
}, [storageError])
useEffect(() => {
- if (!isGitHub || mode !== 'new' || createdStorageRef) {
- setNameAvailability(null)
- setCheckingName(false)
- return undefined
- }
-
- const trimmed = newRepoName.trim()
- if (!trimmed) {
- setNameAvailability(null)
- setCheckingName(false)
- return undefined
- }
-
- // Instant feedback when the loaded repo list already contains this name.
- const localMatch = findRepoByName(storages, trimmed)
- if (localMatch) {
- setCheckingName(false)
- setNameAvailability({
- name: trimmed,
- normalizedName: localMatch.name,
- full_name: localMatch.full_name,
- status: 'taken',
- message: `A repository named "${localMatch.name}" already exists on your account.`,
- })
- return undefined
- }
-
- let cancelled = false
- setCheckingName(true)
- const timer = setTimeout(async () => {
- try {
- const result = await client.checkRepoNameAvailability(trimmed)
- if (!cancelled) {
- setNameAvailability(result)
- }
- } catch (err) {
- if (!cancelled) {
- setNameAvailability({
- name: trimmed,
- normalizedName: null,
- full_name: null,
- status: 'error',
- message: err.message || 'Could not check repository name availability.',
- })
- }
- } finally {
- if (!cancelled) {
- setCheckingName(false)
- }
- }
- }, NAME_CHECK_DEBOUNCE_MS)
-
- return () => {
- cancelled = true
- clearTimeout(timer)
- }
- }, [isGitHub, mode, newRepoName, client, createdStorageRef, storages])
-
- const runValidation = useCallback(async (storageRef) => {
- if (!storageRef) {
+ if (!isGitHub) return
+ if (needsInstall) {
setValidation(null)
return
}
- setValidating(true)
- setError(null)
- try {
- const result = await client.validateStorage('github', storageRef)
- setValidation(result)
- if (result?.capabilities?.canWrite) {
- setNeedsInstall(false)
- }
- } catch (err) {
- setValidation(null)
- setError(err.message || 'Failed to validate storage')
- } finally {
- setValidating(false)
- }
- }, [client])
-
- useEffect(() => {
- if (!isGitHub || !activeStorageRef) {
- setValidation(null)
+ if (createdRef) {
+ runValidation(createdRef)
return
}
- if (needsInstall && mode === 'new') {
+ if (selectedHit) {
+ setValidation(selectedHit.validation)
+ } else {
setValidation(null)
- return
- }
- runValidation(activeStorageRef)
- }, [isGitHub, activeStorageRef, runValidation, needsInstall, mode])
-
- const statusUi = validation ? STATUS_UI[validation.status] : null
- const proposedAction = statusUi?.action ?? null
- const canWrite = validation?.capabilities?.canWrite !== false
- const initBlocked = proposedAction === 'init' && validation && !canWrite
- const awaitingCreate =
- mode === 'new' && !activeStorageRef && Boolean(newRepoName.trim())
- const nameUnavailable =
- awaitingCreate &&
- (checkingName ||
- !nameAvailability ||
- nameAvailability.status === 'taken' ||
- nameAvailability.status === 'invalid')
- const confirmBlocked =
- !validation ||
- !proposedAction ||
- validation.status === 'incompatible' ||
- validation.status === 'invalid' ||
- initBlocked ||
- (mode === 'new' && needsInstall) ||
- (mode === 'new' && !activeStorageRef)
-
- const confirmLabel = useMemo(() => {
- if (awaitingCreate) return 'Create repository'
- if (statusUi?.button) return statusUi.button
- return 'Continue'
- }, [awaitingCreate, statusUi])
-
- const primaryDisabled =
- creating ||
- confirming ||
- validating ||
- (mode === 'new' && needsInstall) ||
- (awaitingCreate ? nameUnavailable : confirmBlocked)
-
- const handleCreateRepo = async () => {
- const name = normalizeGitHubRepoName(newRepoName)
- if (!name || creating || nameUnavailable) return
-
- // Reflect normalized name in the input so users see what will be created.
- if (name !== newRepoName) {
- setNewRepoName(name)
}
+ }, [isGitHub, createdRef, selectedHit, needsInstall, runValidation])
+ const handleCreateDefault = async () => {
+ if (creating) return
setCreating(true)
setError(null)
setNeedsInstall(false)
setInstallUrl(null)
try {
- const result = await client.createStorage(name)
- const ref = result.storageRef
- const asListItem = {
- id: ref.id,
- name: ref.name || ref.full_name?.split('/')[1],
- full_name: ref.full_name,
- private: ref.private ?? true,
- html_url: ref.html_url,
- }
- setCreatedStorageRef(asListItem)
- setStorages((prev) => {
- if (prev.some((r) => r.id === asListItem.id)) return prev
- return [asListItem, ...prev]
- })
- setSelectedId(asListItem.id)
-
+ const result = await client.createStorage()
+ const ref = storageRefFromHit({ storageRef: result.storageRef })
+ setCreatedRef(ref)
if (result.needsInstall) {
setNeedsInstall(true)
setInstallUrl(result.installUrl)
@@ -401,34 +222,24 @@ export default function ConnectView({
} else {
setNeedsInstall(false)
setInstallUrl(null)
- await runValidation({
- id: asListItem.id,
- full_name: asListItem.full_name,
- html_url: asListItem.html_url,
- })
+ setConfirming(true)
+ try {
+ await client.setupStorage('github', ref, 'init')
+ onDone()
+ } catch (initErr) {
+ setError(initErr.message || 'Failed to connect storage')
+ await runValidation(ref)
+ } finally {
+ setConfirming(false)
+ }
}
} catch (err) {
- // Probe failures (rate limit / network) may still return storageRef — keep
- // the created repo selected without the "install App" CTA.
if (err.storageRef) {
- const ref = err.storageRef
- const asListItem = {
- id: ref.id,
- name: ref.name || ref.full_name?.split('/')[1],
- full_name: ref.full_name,
- private: ref.private ?? true,
- html_url: ref.html_url,
- }
- setCreatedStorageRef(asListItem)
- setStorages((prev) => {
- if (prev.some((r) => r.id === asListItem.id)) return prev
- return [asListItem, ...prev]
- })
- setSelectedId(asListItem.id)
+ setCreatedRef(storageRefFromHit({ storageRef: err.storageRef }))
setNeedsInstall(false)
setInstallUrl(null)
}
- setError(err.message || 'Failed to create repository')
+ setError(err.message || 'Failed to create storage')
} finally {
setCreating(false)
}
@@ -436,35 +247,27 @@ export default function ConnectView({
const handleRefreshAfterInstall = async () => {
setError(null)
- const list = await loadStorages()
+ const list = await loadDiscovery()
const match =
- (createdStorageRef && list.find((r) => r.id === createdStorageRef.id)) ||
- findRepoByName(list, newRepoName) ||
- createdStorageRef
+ (createdRef && list.find((hit) => hit.storageRef.id === createdRef.id)) ||
+ createdRef
if (!match) {
setError(
'Repository not found yet. Add it to the Vizably GitHub App installation, then refresh again.',
)
return
}
- const ref = storageRefFromRepo(match)
- setCreatedStorageRef({
- id: match.id,
- name: match.name,
- full_name: match.full_name,
- private: match.private,
- html_url: match.html_url,
- })
+ const ref = storageRefFromHit(match.storageRef ? match : { storageRef: match })
+ setCreatedRef(ref)
setNeedsInstall(false)
await runValidation(ref)
}
const handleConfirm = async () => {
- if (mode === 'new' && !activeStorageRef && newRepoName.trim()) {
- await handleCreateRepo()
+ if (emptyAccount) {
+ await handleCreateDefault()
return
}
-
if (confirmBlocked || !activeStorageRef || !proposedAction) return
setConfirming(true)
@@ -481,69 +284,6 @@ export default function ConnectView({
const providerIcon = provider === 'google' ? GoogleMark(20) : Ico('Github', 20)
- const nameCheckUi = (() => {
- if (checkingName) {
- return {
- tone: 'checking',
- label: 'Checking',
- icon: 'Loader2',
- color: 'var(--text-muted)',
- bg: 'var(--bg-inset)',
- border: 'var(--border-default)',
- inputBorder: 'var(--border-strong)',
- message: 'Checking availability…',
- }
- }
- switch (nameAvailability?.status) {
- case 'available':
- return {
- tone: 'available',
- label: 'Available',
- icon: 'CircleCheck',
- color: 'var(--green-700)',
- bg: 'var(--green-50)',
- border: 'var(--green-100)',
- inputBorder: 'var(--green-600)',
- message: nameAvailability.message,
- }
- case 'taken':
- return {
- tone: 'taken',
- label: 'Taken',
- icon: 'CircleX',
- color: 'var(--sev-serious-fg)',
- bg: 'var(--sev-serious-bg)',
- border: 'var(--sev-serious)',
- inputBorder: 'var(--sev-serious)',
- message: nameAvailability.message,
- }
- case 'invalid':
- return {
- tone: 'invalid',
- label: 'Invalid',
- icon: 'TriangleAlert',
- color: 'var(--sev-serious-fg)',
- bg: 'var(--sev-serious-bg)',
- border: 'var(--sev-serious)',
- inputBorder: 'var(--sev-serious)',
- message: nameAvailability.message,
- }
- case 'error':
- return {
- tone: 'error',
- label: 'Retry later',
- icon: 'TriangleAlert',
- color: 'var(--text-body)',
- bg: 'var(--sev-moderate-bg)',
- border: 'var(--sev-moderate)',
- inputBorder: 'var(--sev-moderate)',
- message: nameAvailability.message,
- }
- default:
- return null
- }
- })()
-
if (!isGitHub) {
return (
{Ico('Check', 15, 'currentColor')}
- Where should we save your scans?
+ Connect your Vizably storage
- Vizably writes each report to {pv.article} {pv.unit} in your {pv.name} — you stay in
- control of it.
+ Vizably finds or creates a private {pv.unit} for your scans. You stay in control of it.
- {(error || listError) && (
+ {error && (
- {error || listError}
+ {error}
)}
-
-
setMode('new')}
- icon="Plus"
- title={`Create a new ${pv.unit}`}
- desc={`Create a fresh private ${pv.unitShort} under your GitHub account and set it up for Vizably.`}
- >
-
- {pv.unit.charAt(0).toUpperCase() + pv.unit.slice(1)} name
-
-
-
{Ico(pv.destIcon, 16)}
-
e.stopPropagation()}
- onChange={(e) => {
- const next = e.target.value
- setNewRepoName(next)
- setNameAvailability(null)
- // Only clear create/install state when the typed name no longer
- // matches the repo we just created — avoids extra re-render churn.
- if (
- createdStorageRef &&
- next.trim() !== createdStorageRef.name &&
- next.trim() !== createdStorageRef.full_name
- ) {
- setCreatedStorageRef(null)
- setNeedsInstall(false)
- setInstallUrl(null)
- }
- }}
- disabled={creating}
- aria-describedby="repo-name-availability"
+ {discovering ? (
+
+ Looking for Vizably storage…
+
+ ) : emptyAccount ? (
+
+ No Vizably account store yet. We'll create{' '}
+ {VIZABLY_DEFAULT_STORE_NAME}
+ {' '}(or the next free name) as a private repository under your GitHub account.
+
+ ) : ambiguous ? (
+
+
+ More than one Vizably store was found. Pick which one to use on this device.
+
+
+ setSelectedId(e.target.value)}
+ aria-label="Choose Vizably storage"
style={{
- flex: 1,
- border: 'none',
- outline: 'none',
- font: 'var(--font-code)',
+ width: '100%',
+ height: 42,
+ padding: '0 12px',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--border-strong)',
+ font: 'var(--font-sans)',
fontSize: 'var(--text-sm)',
color: 'var(--text-strong)',
- background: 'transparent',
- }}
- />
- {nameCheckUi && (
-
- {Ico(nameCheckUi.icon, 16, 'currentColor')}
-
- )}
-
- {nameCheckUi && mode === 'new' && (
-
-
-
- {Ico(nameCheckUi.icon, 12, 'currentColor')}
-
- {nameCheckUi.label}
-
-
- {nameCheckUi.message}
-
-
- )}
-
- Creating a repository requires Vizably's GitHub App{' '}
-
- Administration
- {' '}
- permission (create empty private repos). Contents stay limited to repos you
- install Vizably on.
-
- {needsInstall && (
-
(
+
+ {hit.storageRef.full_name}
+ {hit.validation?.manifestSummary?.scanCount != null
+ ? ` — ${hit.validation.manifestSummary.scanCount} scans`
+ : ''}
+
+ ))}
+
+
e.stopPropagation()}
>
-
- Repository created
- {createdStorageRef?.full_name ? (
- <>
- {' '}
- ({createdStorageRef.full_name})
- >
- ) : null}
- . Add it to your Vizably GitHub App installation, then continue.
-
-
-
- )}
-
+ {Ico('ChevronDown', 16)}
+
+
+
+ ) : (
+
+ Using{' '}
+
+ {activeStorageRef?.full_name || VIZABLY_DEFAULT_STORE_NAME}
+
+ .
+
+ )}
- setMode('existing')}
- icon="FolderOpen"
- title={`Use an existing ${pv.unit}`}
- desc={`Pick ${pv.article} ${pv.unit} from your GitHub account.`}
+ {needsInstall && (
+
- {loadingRepos ? (
-
- Loading repositories…
-
- ) : storages.length === 0 ? (
-
- No repositories found. Create one on GitHub or check app installation.
-
- ) : (
-
-
e.stopPropagation()}
- onChange={(e) => setSelectedId(e.target.value)}
- style={{
- width: '100%',
- height: 42,
- padding: '0 12px',
- borderRadius: 'var(--radius-md)',
- border: '1px solid var(--border-strong)',
- font: 'var(--font-sans)',
- fontSize: 'var(--text-sm)',
- color: 'var(--text-strong)',
- background: 'var(--surface-card)',
- appearance: 'none',
- cursor: 'pointer',
- }}
- >
- {storages.map((repo) => (
-
- {repo.full_name}
- {repo.private ? ' (private)' : ''}
-
- ))}
-
-
+ Repository created
+ {createdRef?.full_name ? (
+ <>
+ {' '}
+ ({createdRef.full_name})
+ >
+ ) : null}
+ . Add it to your Vizably GitHub App installation, then continue.
+
+
- )}
- {
- e.stopPropagation()
- loadStorages()
- }}
- style={{
- marginTop: 8,
- background: 'none',
- border: 'none',
- padding: 0,
- color: 'var(--text-link)',
- cursor: 'pointer',
- fontSize: 'var(--text-xs)',
- textDecoration: 'underline',
- }}
- >
- Refresh repository list
-
-
-
+ Open GitHub App install
+
+ )}
+
+ I've added it — refresh
+
+
+
+ )}
{validating && (
@@ -930,7 +501,7 @@ export default function ConnectView({
)}
- {validation && statusUi && !validating && (
+ {validation && statusUi && !validating && !needsInstall && (
{statusUi.detail(validation)}
- {validation.reason === 'repairable' && (
-
- The scan index will be rebuilt when you load this account.
-
- )}
{initBlocked && (
This storage is read-only — you can load saved scans but cannot set up or save new ones.
@@ -1002,7 +568,13 @@ export default function ConnectView({
variant="primary"
size="lg"
style={{ flex: 1 }}
- disabled={primaryDisabled}
+ disabled={
+ discovering ||
+ creating ||
+ confirming ||
+ validating ||
+ (emptyAccount ? false : confirmBlocked)
+ }
onClick={handleConfirm}
iconRight={Ico('ArrowRight', 17, '#fff')}
>
diff --git a/shared/githubRepoName.js b/shared/githubRepoName.js
index a411cea..b6758ac 100644
--- a/shared/githubRepoName.js
+++ b/shared/githubRepoName.js
@@ -20,4 +20,56 @@ function normalizeGitHubRepoName(name) {
.replace(/^-+|-+$/g, '');
}
-module.exports = { normalizeGitHubRepoName };
+/** Prefix applied to every repository Vizably creates. */
+const VIZABLY_REPO_PREFIX = 'viz_';
+
+/** First-choice store name for a new account (`viz_scans`, then `viz_scans-2`, …). */
+const VIZABLY_DEFAULT_STORE_NAME = `${VIZABLY_REPO_PREFIX}scans`;
+
+/**
+ * Normalize then ensure the Vizably create-path prefix.
+ * Idempotent: names that already start with `viz_` (any casing) keep a single prefix.
+ *
+ * @param {unknown} name
+ * @returns {string}
+ */
+function applyVizablyRepoPrefix(name) {
+ const normalized = normalizeGitHubRepoName(name);
+ if (!normalized) {
+ return '';
+ }
+ if (normalized.toLowerCase().startsWith(VIZABLY_REPO_PREFIX)) {
+ return `${VIZABLY_REPO_PREFIX}${normalized.slice(VIZABLY_REPO_PREFIX.length)}`;
+ }
+ return `${VIZABLY_REPO_PREFIX}${normalized}`;
+}
+
+/**
+ * Next unused create-path name. Tries `viz_scans`, then `viz_scans-2`, …
+ *
+ * @param {Iterable} [takenNames]
+ * @returns {string}
+ */
+function nextVizablyStoreName(takenNames = []) {
+ const taken = new Set(
+ [...takenNames].map((name) => String(name ?? '').toLowerCase()).filter(Boolean),
+ );
+ if (!taken.has(VIZABLY_DEFAULT_STORE_NAME)) {
+ return VIZABLY_DEFAULT_STORE_NAME;
+ }
+ for (let n = 2; n <= 999; n += 1) {
+ const candidate = `${VIZABLY_DEFAULT_STORE_NAME}-${n}`;
+ if (!taken.has(candidate)) {
+ return candidate;
+ }
+ }
+ throw new Error('Could not find an available Vizably repository name');
+}
+
+module.exports = {
+ VIZABLY_REPO_PREFIX,
+ VIZABLY_DEFAULT_STORE_NAME,
+ normalizeGitHubRepoName,
+ applyVizablyRepoPrefix,
+ nextVizablyStoreName,
+};