Add plugin-based organization quick setup
This commit is contained in:
1 parent
bc728e5927
commit
c09570d696
15 files changed
+374
-22
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
# Quick Setup implementation
|
||||||
|
|
||||||
|
Baseline: `bc728e592`. The brief is `/mnt/storage/scratch/mosaic-lead/uidev/org-release/QUICK-SETUP-BRIEF.md`.
|
||||||
|
|
||||||
|
The optional `orgPresets()` export uses the existing harness and model-provider kinds. This avoids introducing a kind and keeps each preset next to the module it selects, at the cost of discovering both kinds for setup. Duplicate names and malformed descriptions refuse rather than choosing one. The legacy `--fixture local` alias is retained. Claude availability uses the Console process environment and returns a generic refusal without paths or values.
|
||||||
|
|
||||||
|
Quick Setup explicitly asks for local-only loss-risk acceptance, because organization checkout setup already requires it even when the project's remote is supplied. The flow does not automatically retry a refused or uncertain step; an owner can open the organization and inspect its receipts. Screenshots use the actual page and organization service with a deliberately refusing bus double. A separate live browser test used a bootstrapped scratch home, the real loopback fixture, the Console, bus, worker and browser: Quick Setup completed all four steps and the conversation received PINEAPPLE. The fixture endpoint ran under a long-lived parent. A short organization ID was needed to fit the Unix socket path limit.
|
||||||
|
|
||||||
|
Follow-ups outside this brief: purpose-based role questions, proxy initialization, organization retirement, and theming.
|
||||||
|
|
||||||
|
The opt-in live test `packages/webui/tests/org-quick-live.test.mjs` reads the Console address from the scratch home's `orgdev-qconsole.log`; it skips in the ordinary package suite when that log is absent. The live test was run with the brief's clean Node 26 environment and `HOME=/mnt/storage/scratch/tmp/oLGvYu`.
|
||||||
|
|
||||||
|
Verification, Node 26 clean environment: business 346 pass/0 fail, plugins 155/0, WebUI 1012 pass/8 fail/1 skip. All eight WebUI failures are `pinned-copy.test.mjs` `ERR_FS_EISDIR` from the brief-required `packages/webui/node_modules` symlink; the skipped case is the separately passing live browser test. Live test: 1 pass/0 fail. The browser test also checks that an organization can be opened after setup, including its repository and the fixture conversation. Screenshots remain untracked.
|
||||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { validateBusinessDocument, httpsUrl } from './business.mjs';
|
import { validateBusinessDocument, httpsUrl } from './business.mjs';
|
||||||
import { ID_PATTERN } from './vocabulary.mjs';
|
import { ID_PATTERN } from './vocabulary.mjs';
|
||||||
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry } from './org-registry.mjs';
|
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry } from './org-registry.mjs';
|
||||||
import { FIXTURE_TEMPLATE } from '../../plugins/model-provider/fixture/index.mjs';
|
import { loadInstalled } from '../../plugins/src/loader.mjs';
|
||||||
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
||||||
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
||||||
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
|
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
|
||||||
@@ -49,15 +49,58 @@ function normalizeDoc(ctx, id, input, existing) {
|
|||||||
if (existing) for (const [p, entry] of Object.entries(existing.projects)) if (entry.git && !doc.projects?.[p]) fail('org-invalid', 'initialized project cannot be removed', `$.projects.${p}`);
|
if (existing) for (const [p, entry] of Object.entries(existing.projects)) if (entry.git && !doc.projects?.[p]) fail('org-invalid', 'initialized project cannot be removed', `$.projects.${p}`);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
export async function templateRender(id, { fixture } = {}) {
|
// Presets are supplied by installed modules; reject malformed or duplicate names
|
||||||
if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture'); checkId(id);
|
// rather than silently choosing a different module's configuration.
|
||||||
|
async function discoverPresets() {
|
||||||
|
const presets = new Map();
|
||||||
|
for (const kind of ['harness', 'model-provider']) {
|
||||||
|
const { modules, refused } = await loadInstalled(kind);
|
||||||
|
if (refused.length) fail('org-invalid', 'installed plugin could not be loaded');
|
||||||
|
for (const { module } of modules) {
|
||||||
|
if (module.orgPresets === undefined) continue;
|
||||||
|
if (typeof module.orgPresets !== 'function') fail('org-invalid', 'invalid preset provider');
|
||||||
|
const list = await module.orgPresets();
|
||||||
|
if (!Array.isArray(list)) fail('org-invalid', 'invalid preset provider');
|
||||||
|
for (const p of list) {
|
||||||
|
if (!p || typeof p.name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(p.name) || presets.has(p.name) ||
|
||||||
|
typeof p.label !== 'string' || !p.label || typeof p.description !== 'string' || !p.description ||
|
||||||
|
!Array.isArray(p.thinking?.levels) || !p.thinking.levels.length ||
|
||||||
|
p.thinking.levels.some(v => typeof v !== 'string' || !/^[a-z][a-z0-9-]*$/.test(v)) ||
|
||||||
|
new Set(p.thinking.levels).size !== p.thinking.levels.length || !p.thinking.levels.includes(p.thinking.default) ||
|
||||||
|
typeof p.available !== 'function' || typeof p.overlay !== 'function') fail('org-invalid', 'invalid or duplicate preset');
|
||||||
|
presets.set(p.name, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
export async function orgPresets({ env = process.env } = {}) {
|
||||||
|
const presets = await discoverPresets();
|
||||||
|
return Promise.all([...presets.values()].map(async p => {
|
||||||
|
const availability = await p.available({ env });
|
||||||
|
if (!availability || typeof availability.ok !== 'boolean' || (!availability.ok && (typeof availability.reason !== 'string' || !availability.reason))) fail('org-invalid', 'invalid preset availability');
|
||||||
|
return { name: p.name, label: p.label, description: p.description, thinking: structuredClone(p.thinking), available: availability.ok ? { ok: true } : { ok: false, reason: availability.reason } };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
export async function templateRender(id, { fixture, preset, thinking } = {}) {
|
||||||
|
if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture');
|
||||||
|
if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
|
||||||
|
if (fixture === 'local') preset = 'fixture';
|
||||||
|
checkId(id);
|
||||||
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
|
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
|
||||||
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
|
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
|
||||||
if (fixture === 'local') {
|
if (preset !== undefined) {
|
||||||
const fixtureDoc = structuredClone(FIXTURE_TEMPLATE);
|
if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
|
||||||
doc.plugins = fixtureDoc.plugins; doc.launch = fixtureDoc.launch;
|
const p = (await discoverPresets()).get(preset);
|
||||||
doc.roles.assistant.vars = fixtureDoc.assistantVars; doc.vars = fixtureDoc.vars;
|
if (!p) fail('org-invalid', 'unknown preset');
|
||||||
}
|
const level = thinking === undefined ? p.thinking.default : thinking;
|
||||||
|
if (!p.thinking.levels.includes(level)) fail('org-invalid', 'unknown thinking level');
|
||||||
|
const overlay = await p.overlay({ thinking: level });
|
||||||
|
if (!overlay || typeof overlay !== 'object' || !overlay.plugins || !overlay.launch || !overlay.assistantVars) fail('org-invalid', 'invalid preset overlay');
|
||||||
|
doc.plugins = structuredClone(overlay.plugins); doc.launch = structuredClone(overlay.launch);
|
||||||
|
doc.roles.assistant.vars = structuredClone(overlay.assistantVars);
|
||||||
|
if (overlay.vars !== undefined) doc.vars = structuredClone(overlay.vars);
|
||||||
|
} else if (thinking !== undefined) fail('org-invalid', 'thinking requires a preset');
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
export async function validateOrg(doc) {
|
export async function validateOrg(doc) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as service from '../src/org-service.mjs';
|
||||||
|
const { templateRender, validateOrg } = service;
|
||||||
|
|
||||||
|
test('installed modules supply presets and report availability without credentials', async () => {
|
||||||
|
const presets = await service.orgPresets({ env: { PATH: '/usr/bin:/bin' } });
|
||||||
|
const fixture = presets.find(p => p.name === 'fixture');
|
||||||
|
const claude = presets.find(p => p.name === 'claude-code');
|
||||||
|
assert.equal(fixture.available.ok, true);
|
||||||
|
assert.equal(claude.available.ok, false);
|
||||||
|
assert.ok(claude.available.reason);
|
||||||
|
assert.deepEqual(claude.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preset overlays produce valid documents and the legacy alias remains identical', async () => {
|
||||||
|
const doc = await templateRender('alpha', { preset: 'fixture' });
|
||||||
|
assert.deepEqual(doc, await templateRender('alpha', { fixture: 'local' }));
|
||||||
|
assert.deepEqual(await validateOrg(doc), { ok: true, errors: [] });
|
||||||
|
assert.equal(doc.vars['bus.tester'], true);
|
||||||
|
const claude = await templateRender('alpha', { preset: 'claude-code', thinking: 'medium' });
|
||||||
|
assert.deepEqual(claude.roles.assistant.vars, { harness: 'claude-code', model: 'opus', thinking: 'medium' });
|
||||||
|
assert.equal(claude.launch.models.opus.model, 'claude-opus-5-5');
|
||||||
|
assert.equal(claude.vars, undefined);
|
||||||
|
assert.deepEqual(await validateOrg(claude), { ok: true, errors: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown presets and thinking levels refuse', async () => {
|
||||||
|
await assert.rejects(templateRender('alpha', { preset: 'no-such-preset' }), /unknown preset/);
|
||||||
|
await assert.rejects(templateRender('alpha', { preset: 'claude-code', thinking: 'off' }), /thinking/);
|
||||||
|
await assert.rejects(templateRender('alpha', { thinking: 'low' }), /thinking/);
|
||||||
|
});
|
||||||
@@ -48,6 +48,20 @@ hold booleans. The harness receives it as data. Modules return named refusals fo
|
|||||||
they never return credentials, commands or endpoints. The host rejects an
|
they never return credentials, commands or endpoints. The host rejects an
|
||||||
unknown or ambiguous legacy mapping and validates transport data before use.
|
unknown or ambiguous legacy mapping and validates transport data before use.
|
||||||
|
|
||||||
|
### Organization presets
|
||||||
|
|
||||||
|
A `harness` or `model-provider` module may export `orgPresets()`, returning an
|
||||||
|
array of preset descriptions. Each has a globally unique stable `name`, a
|
||||||
|
`label`, a one-line `description`, `thinking: { levels, default }`, an
|
||||||
|
`available({ env })` function returning `{ ok: true }` or `{ ok: false,
|
||||||
|
reason }`, and `overlay({ thinking })` returning `{ plugins, launch,
|
||||||
|
assistantVars, vars? }`. The loader discovers installed modules; the
|
||||||
|
organization service rejects duplicate names, invalid metadata, unknown
|
||||||
|
presets and unsupported thinking levels. Availability reasons are displayed
|
||||||
|
in the Console and must never contain credential values or private paths.
|
||||||
|
The fixture provider supplies the legacy `--fixture local` alias; the
|
||||||
|
Claude Code harness checks its preflight using the Console's environment.
|
||||||
|
|
||||||
A `search-source` answers the broker's `web.search` (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): `search(settings, query, deps, { signal })` resolves `{ query, total, results: [{ title, url, snippet }] }` or throws a `WebRefusal`; `signal` is an `AbortSignal` the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: `searxng-url` queries the operator's SearXNG at the business var `web.searxng`; `searxng-bundled` queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); `harness-builtin` is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and `search` only refuses. The choice and every change to it are the `plugins` setting, logged in `changes.jsonl` by the plug-in setter.
|
A `search-source` answers the broker's `web.search` (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): `search(settings, query, deps, { signal })` resolves `{ query, total, results: [{ title, url, snippet }] }` or throws a `WebRefusal`; `signal` is an `AbortSignal` the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: `searxng-url` queries the operator's SearXNG at the business var `web.searxng`; `searxng-bundled` queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); `harness-builtin` is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and `search` only refuses. The choice and every change to it are the `plugins` setting, logged in `changes.jsonl` by the plug-in setter.
|
||||||
|
|
||||||
Adding a kind, or changing a kind's exports, interface version or switch,
|
Adding a kind, or changing a kind's exports, interface version or switch,
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ export function preflight({ env, proxy = null }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function orgPresets() {
|
||||||
|
return [{ name: 'claude-code', label: "Claude via this machine's sign-in",
|
||||||
|
description: 'Use Claude Code with the credentials available to this Console.',
|
||||||
|
thinking: { levels: [...LEVELS], default: 'low' },
|
||||||
|
available: ({ env }) => {
|
||||||
|
const problem = preflight({ env });
|
||||||
|
return problem ? { ok: false, reason: problem.code === 'no-session-credential' ? 'No session credential is available to the Console.' : 'Claude Code is not available to the Console.' } : { ok: true };
|
||||||
|
},
|
||||||
|
overlay: ({ thinking }) => ({ plugins: { channel: ['cli', 'webui'], harness: ['claude-code'] },
|
||||||
|
launch: { by: 'chief', instances: ['assistant'], models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } } },
|
||||||
|
assistantVars: { harness: 'claude-code', model: 'opus', thinking } }) }];
|
||||||
|
}
|
||||||
|
|
||||||
// `claude --version` from the binary sessions run, or null when it doesn't answer.
|
// `claude --version` from the binary sessions run, or null when it doesn't answer.
|
||||||
export function version({ env }) {
|
export function version({ env }) {
|
||||||
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export const FIXTURE_TEMPLATE = Object.freeze({
|
|||||||
assistantVars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' },
|
assistantVars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' },
|
||||||
vars: { 'bus.tester': true },
|
vars: { 'bus.tester': true },
|
||||||
});
|
});
|
||||||
|
// Organization setup is supplied by this module, alongside its model route.
|
||||||
|
export function orgPresets() {
|
||||||
|
return [{ name: 'fixture', label: 'Test model', description: 'Canned replies, no cost.',
|
||||||
|
thinking: { levels: ['off'], default: 'off' }, available: () => ({ ok: true }),
|
||||||
|
overlay: ({ thinking }) => ({ ...structuredClone(FIXTURE_TEMPLATE), assistantVars: { ...FIXTURE_TEMPLATE.assistantVars, thinking } }) }];
|
||||||
|
}
|
||||||
const HARNESSES = Object.freeze(['pi']);
|
const HARNESSES = Object.freeze(['pi']);
|
||||||
const TRANSPORT = Object.freeze({ routeId: 'fixture', endpoint: 'root', api: 'anthropic-messages', piProvider: 'mosaic-fixture', compat: null });
|
const TRANSPORT = Object.freeze({ routeId: 'fixture', endpoint: 'root', api: 'anthropic-messages', piProvider: 'mosaic-fixture', compat: null });
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ export function createOrgAdmin({ service, registry = null, bus = null, context }
|
|||||||
// the page's own origin for the write fence.
|
// the page's own origin for the write fence.
|
||||||
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
|
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
|
||||||
const org = path.match(ROUTE);
|
const org = path.match(ROUTE);
|
||||||
const top = ['/api/org-template', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
|
const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
|
||||||
if (!org && !top) return false;
|
if (!org && !top) return false;
|
||||||
if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'organization setup is the owner\'s' }); return true; }
|
if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'organization setup is the owner\'s' }); return true; }
|
||||||
if (!admin) { json(res, 503, { error: 'not-configured' }); return true; }
|
if (!admin) { json(res, 503, { error: 'not-configured' }); return true; }
|
||||||
@@ -380,12 +380,20 @@ const gitOptions = (body, res, step) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function route(req, res, { path, search, org, admin, principal, write, log }) {
|
async function route(req, res, { path, search, org, admin, principal, write, log }) {
|
||||||
|
if (path === '/api/org-presets' && !write) {
|
||||||
|
if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' });
|
||||||
|
const service = await need(admin, 'service', res, 'presets'); if (!service) return;
|
||||||
|
try { return json(res, 200, { presets: await service.orgPresets() }); }
|
||||||
|
catch (e) { return refusal(res, e, { step: 'presets' }); }
|
||||||
|
}
|
||||||
if (path === '/api/org-template' && !write) {
|
if (path === '/api/org-template' && !write) {
|
||||||
const id = new URLSearchParams(search).get('id') || 'new-org';
|
const params = new URLSearchParams(search);
|
||||||
|
for (const [key] of params) if (!['id', 'preset', 'thinking'].includes(key) || params.getAll(key).length !== 1) return json(res, 422, { error: 'invalid', message: 'invalid template query' });
|
||||||
|
const id = params.has('id') ? params.get('id') : 'new-org';
|
||||||
if (!ID.test(id)) return json(res, 422, { error: 'invalid', errors: [{ path: '$.id', field: 'id', message: 'invalid organization id' }] });
|
if (!ID.test(id)) return json(res, 422, { error: 'invalid', errors: [{ path: '$.id', field: 'id', message: 'invalid organization id' }] });
|
||||||
const service = await need(admin, 'service', res, 'template'); if (!service) return;
|
const service = await need(admin, 'service', res, 'template'); if (!service) return;
|
||||||
let doc;
|
let doc;
|
||||||
try { doc = await service.templateRender(id, { fixture: 'local' }); } catch (e) { return refusal(res, e, { step: 'template' }); }
|
try { doc = await service.templateRender(id, params.has('preset') || params.has('thinking') ? { ...(params.has('preset') ? { preset: params.get('preset') } : {}), ...(params.has('thinking') ? { thinking: params.get('thinking') } : {}) } : { fixture: 'local' }); } catch (e) { return refusal(res, e, { step: 'template' }); }
|
||||||
const shown = forBrowser(doc);
|
const shown = forBrowser(doc);
|
||||||
return json(res, 200, { id, doc: shown.doc, held: shown.held, verb: verbFor('create', id) });
|
return json(res, 200, { id, doc: shown.doc, held: shown.held, verb: verbFor('create', id) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
|||||||
}
|
}
|
||||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||||
window.fetch = (input, init) => {
|
window.fetch = (input, init) => {
|
||||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input))
|
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input))
|
||||||
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
|
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
|
||||||
return nativeFetch(input, init);
|
return nativeFetch(input, init);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,3 +74,5 @@
|
|||||||
.org-step .settings-actions{margin-top:8px}
|
.org-step .settings-actions{margin-top:8px}
|
||||||
@media(max-width:959px){.org-columns{grid-template-columns:minmax(0,1fr)}.org-json{position:static}.settings-content .org-json textarea{min-height:280px}}
|
@media(max-width:959px){.org-columns{grid-template-columns:minmax(0,1fr)}.org-json{position:static}.settings-content .org-json textarea{min-height:280px}}
|
||||||
@media(max-width:759px){.org-start-row{grid-template-columns:minmax(0,1fr);gap:16px}.org-row{grid-template-columns:minmax(0,1fr) auto}.org-row .org-field:nth-child(2){grid-column:1/2}.org-row .org-remove{grid-column:2;grid-row:1}.settings-content .org-admin .settings-actions button{flex:1}}
|
@media(max-width:759px){.org-start-row{grid-template-columns:minmax(0,1fr);gap:16px}.org-row{grid-template-columns:minmax(0,1fr) auto}.org-row .org-field:nth-child(2){grid-column:1/2}.org-row .org-remove{grid-column:2;grid-row:1}.settings-content .org-admin .settings-actions button{flex:1}}
|
||||||
|
|
||||||
|
.org-quick-risk{display:flex;align-items:flex-start;gap:8px;margin:12px 0;line-height:1.5}.org-quick-risk input{margin-top:4px}.settings-content .org-path-actions button{display:inline-flex;align-items:center;justify-content:center}
|
||||||
@@ -25,6 +25,21 @@
|
|||||||
</section>
|
</section>
|
||||||
<div id="org-status" class="sr-only" role="status" aria-live="polite"></div>
|
<div id="org-status" class="sr-only" role="status" aria-live="polite"></div>
|
||||||
<div id="org-banner" class="org-banner" role="alert" hidden></div>
|
<div id="org-banner" class="org-banner" role="alert" hidden></div>
|
||||||
|
<section id="org-paths" class="panel" hidden aria-labelledby="org-paths-title">
|
||||||
|
<h3 id="org-paths-title">Choose how to set up</h3>
|
||||||
|
<div class="org-field"><label for="org-preset">Who answers?</label><select id="org-preset"></select><span class="org-help" id="org-preset-help"></span></div>
|
||||||
|
<div class="org-field"><label for="org-thinking">How much thinking?</label><select id="org-thinking"></select></div>
|
||||||
|
<div class="settings-actions org-path-actions"><button type="button" id="org-quick-open">Quick Setup</button><button type="button" class="org-secondary" id="org-advanced-open">Advanced Setup</button></div>
|
||||||
|
</section>
|
||||||
|
<section id="org-quick" class="panel" hidden aria-labelledby="org-quick-title">
|
||||||
|
<h3 id="org-quick-title">Quick Setup</h3>
|
||||||
|
<div class="org-field"><label for="org-project">First project name</label><input id="org-project" value="work" autocomplete="off"><span class="org-help">Lowercase letters, digits, dot, dash or underscore.</span></div>
|
||||||
|
<div class="org-field"><label for="org-project-remote">Project remote URL (optional)</label><input id="org-project-remote" type="url" placeholder="https://git.example/team/project"><span class="org-help">Leave blank for local only. The organization checkout is local only.</span></div>
|
||||||
|
<label class="org-quick-risk"><input id="org-risk-accept" type="checkbox"> I understand local-only repositories have no copy elsewhere and can be lost.</label>
|
||||||
|
<div class="settings-actions"><button type="button" id="org-quick-run">Create and set up organization</button></div>
|
||||||
|
<ol id="org-quick-steps" class="org-steps" aria-live="polite"></ol>
|
||||||
|
<div class="settings-actions"><button type="button" id="org-quick-open-org" hidden>Open organization</button></div>
|
||||||
|
</section>
|
||||||
<section id="org-editor" class="org-editor" hidden aria-labelledby="org-editor-title">
|
<section id="org-editor" class="org-editor" hidden aria-labelledby="org-editor-title">
|
||||||
<div class="org-editor-head"><h3 id="org-editor-title">New organization</h3><p id="org-editor-verb" class="org-verb"></p></div>
|
<div class="org-editor-head"><h3 id="org-editor-title">New organization</h3><p id="org-editor-verb" class="org-verb"></p></div>
|
||||||
<div id="org-errors" class="org-errors" hidden tabindex="-1" aria-labelledby="org-errors-title"><h4 id="org-errors-title">Fix these fields</h4><ul></ul></div>
|
<div id="org-errors" class="org-errors" hidden tabindex="-1" aria-labelledby="org-errors-title"><h4 id="org-errors-title">Fix these fields</h4><ul></ul></div>
|
||||||
@@ -51,7 +66,7 @@
|
|||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
const root = $('org-admin'); if (!root) return () => {};
|
const root = $('org-admin'); if (!root) return () => {};
|
||||||
let alive = true, canWrite = false, editorDirty = false;
|
let alive = true, canWrite = false, editorDirty = false, presets = [];
|
||||||
const setupDirty = new Set();
|
const setupDirty = new Set();
|
||||||
window.MosaicOrgCreate.hasDraft = () => editorDirty || setupDirty.size > 0 || !!$('org-new-id')?.value.trim();
|
window.MosaicOrgCreate.hasDraft = () => editorDirty || setupDirty.size > 0 || !!$('org-new-id')?.value.trim();
|
||||||
// mode: 'create' | 'edit'; doc: the one canonical (browser) document.
|
// mode: 'create' | 'edit'; doc: the one canonical (browser) document.
|
||||||
@@ -206,19 +221,87 @@
|
|||||||
const input = $('org-new-id'), id = input.value.trim();
|
const input = $('org-new-id'), id = input.value.trim();
|
||||||
if (!ID.test(id)) { input.setAttribute('aria-invalid', 'true'); $('org-new-id-help').textContent = 'Organization ID: use 1-64 lowercase letters, digits, dot, dash or underscore, starting with a letter or digit.'; $('org-new-id-help').classList.add('org-field-err'); input.focus(); status('Organization ID is not valid.'); return; }
|
if (!ID.test(id)) { input.setAttribute('aria-invalid', 'true'); $('org-new-id-help').textContent = 'Organization ID: use 1-64 lowercase letters, digits, dot, dash or underscore, starting with a letter or digit.'; $('org-new-id-help').classList.add('org-field-err'); input.focus(); status('Organization ID is not valid.'); return; }
|
||||||
input.removeAttribute('aria-invalid'); $('org-new-id-help').classList.remove('org-field-err'); $('org-new-id-help').textContent = 'Lowercase letters, digits, dot, dash or underscore.';
|
input.removeAttribute('aria-invalid'); $('org-new-id-help').classList.remove('org-field-err'); $('org-new-id-help').textContent = 'Lowercase letters, digits, dot, dash or underscore.';
|
||||||
const r = await api('GET', `/api/org-template?id=${encodeURIComponent(id)}`);
|
const r = await api('GET', '/api/org-presets');
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (r.status !== 200) { banner(r.body.message || `The template could not be read (${r.body.error || r.status}).`); return; }
|
if (r.status !== 200) { banner(r.body.message || `Presets could not be read (${r.body.error || r.status}).`); return; }
|
||||||
banner(''); $('org-receipt').hidden = true; $('org-setup').hidden = true; setQuery(null);
|
presets = r.body.presets;
|
||||||
openEditor('create', id, r.body.doc, { held: r.body.held || [], verb: r.body.verb });
|
st.id = id; st.mode = 'create'; editorDirty = true;
|
||||||
status(`Template loaded for ${id}. The form and the JSON editor show the same document.`);
|
$('org-preset').innerHTML = presets.map(p => `<option value="${txt(p.name)}"${p.available.ok ? '' : ' disabled'}>${txt(p.label)}${p.available.ok ? '' : ` — ${txt(p.available.reason)}`}</option>`).join('');
|
||||||
|
$('org-preset').value = presets.find(p => p.available.ok)?.name || '';
|
||||||
|
updatePreset();
|
||||||
|
$('org-paths').hidden = false; $('org-quick').hidden = true; $('org-editor').hidden = true; $('org-receipt').hidden = true; $('org-setup').hidden = true;
|
||||||
|
$('org-quick-steps').innerHTML = ''; $('org-quick-open-org').hidden = true; banner(''); setQuery(null);
|
||||||
|
$('org-preset').focus();
|
||||||
|
}
|
||||||
|
function updatePreset() {
|
||||||
|
const p = presets.find(v => v.name === $('org-preset').value);
|
||||||
|
$('org-preset-help').textContent = p?.description || 'No available presets.';
|
||||||
|
$('org-thinking').innerHTML = (p?.thinking.levels || []).map(v => `<option value="${txt(v)}"${v === p.thinking.default ? ' selected' : ''}>${txt(v)}</option>`).join('');
|
||||||
|
$('org-quick-open').disabled = !p || !canWrite; $('org-advanced-open').disabled = !p || !canWrite;
|
||||||
|
}
|
||||||
|
async function selectedTemplate() {
|
||||||
|
const q = new URLSearchParams({ id: st.id, preset: $('org-preset').value, thinking: $('org-thinking').value });
|
||||||
|
return api('GET', `/api/org-template?${q}`);
|
||||||
|
}
|
||||||
|
async function openAdvanced() {
|
||||||
|
const r = await selectedTemplate(); if (!alive) return;
|
||||||
|
if (r.status !== 200) { banner(r.body.message || `Template refused (${r.body.error || r.status}).`); return; }
|
||||||
|
$('org-quick').hidden = true; $('org-paths').hidden = true;
|
||||||
|
openEditor('create', st.id, r.body.doc, { held: r.body.held || [], verb: r.body.verb });
|
||||||
root.querySelector('[data-path="$.human"]')?.focus();
|
root.querySelector('[data-path="$.human"]')?.focus();
|
||||||
}
|
}
|
||||||
|
function openQuick() {
|
||||||
|
$('org-quick').hidden = false; $('org-editor').hidden = true;
|
||||||
|
$('org-project').focus();
|
||||||
|
}
|
||||||
|
async function quickRun() {
|
||||||
|
const project = $('org-project').value.trim(), remote = $('org-project-remote').value.trim();
|
||||||
|
if (!ID.test(project)) { $('org-project').setAttribute('aria-invalid', 'true'); banner('Enter a valid project name.'); $('org-project').focus(); return; }
|
||||||
|
$('org-project').removeAttribute('aria-invalid');
|
||||||
|
if (remote && !/^https:\/\/[^\s/]+\/\S+$/.test(remote)) { banner('Enter an https repository URL or leave it blank.'); $('org-project-remote').focus(); return; }
|
||||||
|
if (!$('org-risk-accept').checked) { banner('Accept the risk of local-only repositories to continue.'); $('org-risk-accept').focus(); return; }
|
||||||
|
const id = st.id, enc = encodeURIComponent, local = { mode: 'local-only', acceptLocalLossRisk: true };
|
||||||
|
const steps = [
|
||||||
|
{ title: 'Create organization', verb: `mosaic org create ${id} --from <file>` },
|
||||||
|
{ title: 'Initialize organization checkout', verb: `mosaic org queue init ${id} --local-only --accept-local-loss-risk` },
|
||||||
|
{ title: `Initialize project repository: ${project}`, verb: `mosaic org project repo init ${id} ${project} ${remote ? `--remote ${remote}` : '--local-only --accept-local-loss-risk'}` },
|
||||||
|
{ title: 'Start the bus', verb: `mosaic bus start ${id} --dev-local-owner --detach` },
|
||||||
|
];
|
||||||
|
const list = $('org-quick-steps');
|
||||||
|
list.innerHTML = steps.map((s, i) => `<li class="panel org-step" data-quick-step="${i}" tabindex="-1"><div class="org-step-head"><h4>${txt(s.title)}</h4><span class="org-pill">Not started</span></div><p class="org-verb">Same as <code>${txt(s.verb)}</code></p><p class="org-field-err" role="alert"></p></li>`).join('');
|
||||||
|
const run = $('org-quick-run'); run.disabled = true; $('org-quick-open').disabled = true; $('org-advanced-open').disabled = true; $('org-preset').disabled = true; $('org-thinking').disabled = true; banner('');
|
||||||
|
let current = 0;
|
||||||
|
try {
|
||||||
|
const template = await selectedTemplate();
|
||||||
|
if (template.status !== 200) throw Error(template.body.message || 'Could not load the template.');
|
||||||
|
const doc = template.body.doc; doc.projects = { [project]: {} };
|
||||||
|
const requests = [
|
||||||
|
['/api/orgs', { id, doc }],
|
||||||
|
[`/api/orgs/${enc(id)}/setup/queue`, local],
|
||||||
|
[`/api/orgs/${enc(id)}/setup/project/${enc(project)}`, remote ? { mode: 'remote', remote } : local],
|
||||||
|
[`/api/orgs/${enc(id)}/setup/bus`, {}],
|
||||||
|
];
|
||||||
|
for (current = 0; current < requests.length; current++) {
|
||||||
|
const card = list.children[current]; card.querySelector('.org-pill').textContent = 'In progress';
|
||||||
|
const result = await api('POST', ...requests[current]);
|
||||||
|
if (result.status !== 200 && result.status !== 201) throw Error(result.body.message || result.body.errors?.map(e => e.message).join(' ') || `Refused (${result.body.error || result.status}).`);
|
||||||
|
card.querySelector('.org-pill').textContent = 'Done'; card.classList.add('org-step-done');
|
||||||
|
if (result.body.verb) card.querySelector('code').textContent = result.body.verb;
|
||||||
|
}
|
||||||
|
editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; status('Organization setup complete.');
|
||||||
|
await refreshList();
|
||||||
|
} catch (e) {
|
||||||
|
const card = list.children[Math.min(current, steps.length - 1)];
|
||||||
|
card.querySelector('.org-pill').textContent = 'Stopped'; card.querySelector('.org-field-err').textContent = `${e.message} Open the organization to inspect and continue; do not repeat a step whose outcome is unknown.`;
|
||||||
|
card.classList.add('org-step-warn'); card.focus(); status('Setup stopped.');
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
}
|
||||||
async function openExisting(id, { toSetup = false } = {}) {
|
async function openExisting(id, { toSetup = false } = {}) {
|
||||||
const r = await api('GET', `/api/orgs/${encodeURIComponent(id)}/admin`);
|
const r = await api('GET', `/api/orgs/${encodeURIComponent(id)}/admin`);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (r.status !== 200) { banner(r.status === 404 ? `Organization ${id} is not available.` : r.body.message || `Organization ${id} could not be read (${r.body.error || r.status}).`); return; }
|
if (r.status !== 200) { banner(r.status === 404 ? `Organization ${id} is not available.` : r.body.message || `Organization ${id} could not be read (${r.body.error || r.status}).`); return; }
|
||||||
banner('');
|
banner(''); $('org-paths').hidden = true; $('org-quick').hidden = true;
|
||||||
openEditor('edit', id, r.body.doc, { revision: r.body.revision, held: r.body.held || [], verb: r.body.verb });
|
openEditor('edit', id, r.body.doc, { revision: r.body.revision, held: r.body.held || [], verb: r.body.verb });
|
||||||
setQuery(id, toSetup ? 'setup' : null);
|
setQuery(id, toSetup ? 'setup' : null);
|
||||||
await loadSetup(id);
|
await loadSetup(id);
|
||||||
@@ -393,6 +476,11 @@
|
|||||||
// ---- wiring -----------------------------------------------------------
|
// ---- wiring -----------------------------------------------------------
|
||||||
const handlers = [
|
const handlers = [
|
||||||
[$('org-new-form'), 'submit', startNew],
|
[$('org-new-form'), 'submit', startNew],
|
||||||
|
[$('org-preset'), 'change', updatePreset],
|
||||||
|
[$('org-quick-open'), 'click', openQuick],
|
||||||
|
[$('org-advanced-open'), 'click', openAdvanced],
|
||||||
|
[$('org-quick-run'), 'click', quickRun],
|
||||||
|
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
|
||||||
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
|
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
|
||||||
[$('org-form'), 'submit', save],
|
[$('org-form'), 'submit', save],
|
||||||
[$('org-form'), 'input', onFormInput],
|
[$('org-form'), 'input', onFormInput],
|
||||||
|
|||||||
@@ -713,7 +713,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
|||||||
for (const registerRoutes of routes) registerRoutes(registered);
|
for (const registerRoutes of routes) registerRoutes(registered);
|
||||||
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
|
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
|
||||||
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
|
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
|
||||||
for (const path of ['/api/org-template', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup)(?:\/(?:queue|bus|project\/[^/]+))?$/]) {
|
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup)(?:\/(?:queue|bus|project\/[^/]+))?$/]) {
|
||||||
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
|
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
|
||||||
}
|
}
|
||||||
registered.add('POST', '/api/orgs', adminRoute);
|
registered.add('POST', '/api/orgs', adminRoute);
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ test('only the owner with --send writes', async t => {
|
|||||||
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
|
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
|
||||||
// Without sign-in there is no owner: a loopback Console with no sso refuses every route.
|
// Without sign-in there is no owner: a loopback Console with no sso refuses every route.
|
||||||
const anon = await console_(t, { sso: null, send: null });
|
const anon = await console_(t, { sso: null, send: null });
|
||||||
for (const [method, path] of [['GET', '/api/org-template?id=acme'], ['GET', '/api/org-admin'], ['GET', '/api/orgs/acme/setup'], ['POST', '/api/orgs']]) {
|
for (const [method, path] of [['GET', '/api/org-template?id=acme'], ['GET', '/api/org-presets'], ['GET', '/api/org-admin'], ['GET', '/api/orgs/acme/setup'], ['POST', '/api/orgs']]) {
|
||||||
const res = await anon.call(method, path, method === 'POST' ? { id: 'acme', doc: browserDoc('acme') } : undefined);
|
const res = await anon.call(method, path, method === 'POST' ? { id: 'acme', doc: browserDoc('acme') } : undefined);
|
||||||
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only');
|
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only');
|
||||||
}
|
}
|
||||||
@@ -419,3 +419,20 @@ test('against the real C2 service: create, set up, resume and refuse an overwrit
|
|||||||
assert.equal(saved.status, 200, saved.raw); assert.equal(saved.body.receipt.op, 'org.update');
|
assert.equal(saved.status, 200, saved.raw); assert.equal(saved.body.receipt.op, 'org.update');
|
||||||
assert.doesNotMatch(JSON.stringify([tpl.body, s.body, shown.body]), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the browser');
|
assert.doesNotMatch(JSON.stringify([tpl.body, s.body, shown.body]), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the browser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preset routes expose installed choices and refuse invalid queries', async t => {
|
||||||
|
const service = await import('../../business/src/org-service.mjs');
|
||||||
|
const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||||
|
const c = await console_(t, { orgAdmin: admin });
|
||||||
|
const presets = await c.call('GET', '/api/org-presets');
|
||||||
|
assert.equal(presets.status, 200);
|
||||||
|
assert.ok(presets.body.presets.some(p => p.name === 'fixture' && p.available.ok));
|
||||||
|
const result = await c.call('GET', '/api/org-template?id=alpha&preset=fixture&thinking=off');
|
||||||
|
assert.equal(result.status, 200);
|
||||||
|
assert.deepEqual(result.body.doc.projects, { work: {} });
|
||||||
|
assert.equal(result.body.doc.roles.assistant.vars.model, 'fixture-deterministic');
|
||||||
|
for (const query of ['id=alpha&preset=unknown', 'id=alpha&preset=fixture&thinking=high', 'id=alpha&thinking=off', 'id=alpha&id=beta', 'id=alpha&other=x']) {
|
||||||
|
assert.equal((await c.call('GET', `/api/org-template?${query}`)).status, 422, query);
|
||||||
|
}
|
||||||
|
assert.equal((await c.call('GET', '/api/org-presets?extra=x')).status, 422);
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { startServer } from '../src/serve.mjs';
|
||||||
|
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||||
|
import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||||
|
import * as service from '../../business/src/org-service.mjs';
|
||||||
|
import { browser } from './browser.mjs';
|
||||||
|
import { close } from './fixture.mjs';
|
||||||
|
|
||||||
|
test('setup paths and preset controls are visible', { timeout: 45000 }, async t => {
|
||||||
|
const admin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||||
|
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
|
||||||
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||||
|
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||||
|
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).display !== 'none' && e.getClientRects().length > 0})()`);
|
||||||
|
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
|
||||||
|
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
|
||||||
|
await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
|
||||||
|
await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
|
||||||
|
for (const selector of ['#org-quick-open', '#org-advanced-open', '#org-preset', '#org-thinking']) assert.equal(await shown(selector), true, selector);
|
||||||
|
await b.evaluate(`document.querySelector('#org-quick-open').click()`);
|
||||||
|
await wait('!document.querySelector("#org-quick").hidden');
|
||||||
|
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const shouldFail of [true, false]) test(`quick setup creates real repositories and ${shouldFail ? 'stops at a bus refusal' : 'completes after bus start'}`, { timeout: 45000 }, async t => {
|
||||||
|
const { mkdtempSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
|
||||||
|
const { tmpdir } = await import('node:os'); const { join } = await import('node:path');
|
||||||
|
const registry = await import('../../business/src/org-registry.mjs');
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'quick-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
|
||||||
|
let starts = 0;
|
||||||
|
const bus = { busStatus: async () => ({ healthy: false }), busStart: async () => { starts++; if (shouldFail) throw Object.assign(Error('bus refused'), { code: 'org-invalid' }); return { healthy: true }; } };
|
||||||
|
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
|
||||||
|
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
|
||||||
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||||
|
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||||
|
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
|
||||||
|
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
|
||||||
|
await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
|
||||||
|
await wait('document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
|
||||||
|
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
|
||||||
|
const finalState = shouldFail ? 'Stopped' : 'Done';
|
||||||
|
await wait(`document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === '${finalState}'`);
|
||||||
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', finalState]);
|
||||||
|
if (shouldFail) {
|
||||||
|
assert.match(await b.evaluate(`document.querySelector('[data-quick-step="3"]').textContent`), /bus refused/);
|
||||||
|
assert.equal(await b.evaluate('document.activeElement.dataset.quickStep'), '3');
|
||||||
|
}
|
||||||
|
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="3"] code').textContent`), 'mosaic bus start alpha --dev-local-owner --detach');
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/checkout/.git')));
|
||||||
|
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/work/.git')));
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Live scratch-only acceptance. Run with HOME set to a bootstrapped scratch
|
||||||
|
// instance whose fixture and Console are already running (see BUILD.md).
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { browser } from './browser.mjs';
|
||||||
|
|
||||||
|
const log = join(homedir(), 'orgdev-qconsole.log');
|
||||||
|
test('Quick Setup starts the real fixture and a browser message gets its canned reply', { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
|
||||||
|
const url = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(readFileSync(log, 'utf8'));
|
||||||
|
assert.ok(url, 'scratch Console advertised a URL');
|
||||||
|
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url[2])), 'Console must use an allowed port');
|
||||||
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||||
|
const wait = async (expression, timeout = 20000) => { const end = Date.now() + timeout; while (Date.now() < end) { try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch {} await new Promise(r => setTimeout(r, 50)); } throw Error(`Timed out: ${expression}`); };
|
||||||
|
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||||
|
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
|
||||||
|
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e&&getComputedStyle(e).display!=='none'&&getComputedStyle(e).visibility!=='hidden'&&e.getClientRects().length>0})()`);
|
||||||
|
const existing = await (await fetch(url[1] + 'api/orgs')).json();
|
||||||
|
const id = '0123456789abcdefghijklmnopqrstuvwxyz'.split('').map(c => 'q' + c).find(candidate => !existing.some(o => o.id === candidate));
|
||||||
|
assert.ok(id, 'a short unused scratch organization ID is available');
|
||||||
|
const start = existing.length ? `#/orgs/${encodeURIComponent(existing[0].id)}/settings/organizations` : '#/settings/organizations';
|
||||||
|
await b.navigate(url[1] + start);
|
||||||
|
await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
|
||||||
|
assert.equal(await shown('#org-new-form [data-write]'), true);
|
||||||
|
await input('#org-new-id', id); await click('#org-new-form [data-write]');
|
||||||
|
await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#org-preset").value'), 'fixture');
|
||||||
|
for (const selector of ['#org-preset', '#org-thinking', '#org-quick-open', '#org-advanced-open']) assert.equal(await shown(selector), true, selector);
|
||||||
|
await click('#org-quick-open');
|
||||||
|
await wait('document.querySelector("#org-quick") && !document.querySelector("#org-quick").hidden');
|
||||||
|
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
|
||||||
|
await click('#org-risk-accept'); await click('#org-quick-run');
|
||||||
|
await wait(`document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
|
||||||
|
console.log('setup reached a terminal step');
|
||||||
|
const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
|
||||||
|
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
|
||||||
|
assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
|
||||||
|
await click('#org-quick-open-org'); console.log('opening organization');
|
||||||
|
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && !!document.querySelector('#chat-repo option[value=work]')`, 30000);
|
||||||
|
await wait('!!document.querySelector("#chat-repo option[value=work]")');
|
||||||
|
console.log('repository offered');
|
||||||
|
await click('.project-create summary'); await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
|
||||||
|
await click('#chat-project-form button[type=submit]');
|
||||||
|
await wait('location.hash.match(/\\/projects\\/[0-9a-f-]{36}/i)');
|
||||||
|
console.log('project created');
|
||||||
|
const projectRoute = await b.evaluate('location.hash');
|
||||||
|
await b.evaluate(`location.hash=${JSON.stringify(projectRoute + '/conversations')}`);
|
||||||
|
await wait('!!document.querySelector("#chat-thread-form")');
|
||||||
|
await click('.project-new-conversation summary'); await input('#chat-thread-name', 'Trial'); await input('#chat-instance', 'assistant');
|
||||||
|
await click('#chat-thread-form button[type=submit]'); await wait('!!document.querySelector("#chat-body")', 30000);
|
||||||
|
console.log('conversation created');
|
||||||
|
await input('#chat-body', 'Reply with fixture word'); await click('#chat-send-form button[type=submit]');
|
||||||
|
await wait('document.querySelector(".chat-log")?.textContent.includes("PINEAPPLE")', 90000);
|
||||||
|
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /PINEAPPLE/); console.log('fixture reply received');
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ test('creating another organization opens its setup, receipt, header and theme',
|
|||||||
const docs = new Map([['alpha', makeDoc('alpha')]]), records = new Map();
|
const docs = new Map([['alpha', makeDoc('alpha')]]), records = new Map();
|
||||||
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
|
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
|
||||||
const service = {
|
const service = {
|
||||||
|
orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }],
|
||||||
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
|
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
|
||||||
createOrg: async (_ctx, id, doc) => { docs.set(id, doc); const receipt = { op: 'org.create', org: id, notifications: 'none' }; records.set(id, receipt); return receipt; },
|
createOrg: async (_ctx, id, doc) => { docs.set(id, doc); const receipt = { op: 'org.create', org: id, notifications: 'none' }; records.set(id, receipt); return receipt; },
|
||||||
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
|
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
|
||||||
@@ -46,6 +47,8 @@ test('creating another organization opens its setup, receipt, header and theme',
|
|||||||
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'), false, 'org setup is not shown as read only');
|
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'), false, 'org setup is not shown as read only');
|
||||||
assert.equal(await shown('#org-new-form [data-write]'), true, 'New organization is visible, not only clickable');
|
assert.equal(await shown('#org-new-form [data-write]'), true, 'New organization is visible, not only clickable');
|
||||||
await b.evaluate(`(()=>{const e=document.querySelector('#org-new-id');e.value='beta';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`);
|
await b.evaluate(`(()=>{const e=document.querySelector('#org-new-id');e.value='beta';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`);
|
||||||
|
await wait('document.querySelector("#org-advanced-open") && !document.querySelector("#org-paths").hidden');
|
||||||
|
await b.evaluate(`document.querySelector('#org-advanced-open').click()`);
|
||||||
await wait('document.querySelector("#org-editor") && !document.querySelector("#org-editor").hidden');
|
await wait('document.querySelector("#org-editor") && !document.querySelector("#org-editor").hidden');
|
||||||
assert.equal(await shown('#org-save'), true, 'Save organization is visible, not only clickable');
|
assert.equal(await shown('#org-save'), true, 'Save organization is visible, not only clickable');
|
||||||
await b.evaluate(`document.querySelector('#org-save').click()`);
|
await b.evaluate(`document.querySelector('#org-save').click()`);
|
||||||
@@ -186,7 +189,7 @@ test('organization creation draft survives Stay and is discarded on switch', asy
|
|||||||
list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }, { id: 'beta', label: 'Beta', busHealthy: true }],
|
list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }, { id: 'beta', label: 'Beta', busHealthy: true }],
|
||||||
resolve: async id => ({ reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }) }, bus: {} }),
|
resolve: async id => ({ reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }) }, bus: {} }),
|
||||||
};
|
};
|
||||||
const service = { templateRender: async id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} } }) };
|
const service = { orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }], templateRender: async id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} } }) };
|
||||||
const orgAdmin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
const orgAdmin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
||||||
t.after(() => close(server));
|
t.after(() => close(server));
|
||||||
@@ -197,6 +200,8 @@ test('organization creation draft survives Stay and is discarded on switch', asy
|
|||||||
await wait('!!document.querySelector("#org-new-id")');
|
await wait('!!document.querySelector("#org-new-id")');
|
||||||
await wait('!document.querySelector("#org-new-form [data-write]").disabled');
|
await wait('!document.querySelector("#org-new-form [data-write]").disabled');
|
||||||
await b.evaluate(`(()=>{const el=document.querySelector('#org-new-id');el.value='draft';el.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`);
|
await b.evaluate(`(()=>{const el=document.querySelector('#org-new-id');el.value='draft';el.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`);
|
||||||
|
await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
|
||||||
|
await b.evaluate(`document.querySelector('#org-advanced-open').click()`);
|
||||||
await wait('!document.querySelector("#org-editor").hidden');
|
await wait('!document.querySelector("#org-editor").hidden');
|
||||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations/);
|
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations/);
|
||||||
await b.evaluate(`(()=>{const el=document.querySelector('[data-path="$.human"]');el.value='unsaved';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);
|
await b.evaluate(`(()=>{const el=document.querySelector('[data-path="$.human"]');el.value='unsaved';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);
|
||||||
|
|||||||
Reference in new issue
Block a user