Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7efa7c43 | ||
|
|
397cc78b86 | ||
|
|
675452a9ef | ||
|
|
f4b4cf3db7 | ||
|
|
c662db2875 | ||
|
|
5efc3a5491 | ||
|
|
61275574d4 | ||
|
|
3121431a7e | ||
|
|
3b8368db3a | ||
|
|
0c722b3a9d | ||
|
|
31913b8648 | ||
|
|
fecf58e54a | ||
|
|
7f076b49ac | ||
|
|
68cada5593 | ||
|
|
9049512e0d | ||
|
|
51255b3583 | ||
|
|
9df2fee295 | ||
|
|
eed48e2bbb | ||
|
|
8044310fba | ||
|
|
d300d62c60 | ||
|
|
bceb44f8af | ||
|
|
9fd5e33cd4 | ||
|
|
0befd4e629 | ||
|
|
e3d29c7d1b | ||
|
|
0e1e1a722d | ||
|
|
2cf74968e0 | ||
|
|
34d6155801 | ||
|
|
e3a1eefb50 | ||
|
|
a00a710e3b | ||
|
|
88b4176cc7 | ||
|
|
8d31fc5f45 | ||
|
|
042f1eb929 | ||
|
|
9fc07c3cd1 | ||
|
|
8249f9e3d3 | ||
|
|
0496dfe533 | ||
|
|
fce1962315 | ||
|
|
c3f8e3541c | ||
|
|
39fac68fc1 | ||
|
|
31ed5c0384 | ||
|
|
3f2e43753d | ||
|
|
547f2d4089 | ||
|
|
b6707f41f2 | ||
|
|
e43f121dfb | ||
|
|
20f074af20 | ||
|
|
35444e02be | ||
|
|
ba5d218841 | ||
|
|
f1621df3e2 | ||
|
|
4a1c553f9d | ||
|
|
39c86ab123 | ||
|
|
d717f0d4a1 |
678
.claude/workflows/release.js
Normal file
678
.claude/workflows/release.js
Normal file
@@ -0,0 +1,678 @@
|
||||
export const meta = {
|
||||
name: 'release',
|
||||
description: 'Relicario release lifecycle: develop features (single/multi-agent), iterate on debug, cut releases',
|
||||
phases: [
|
||||
{ title: 'Discover' },
|
||||
{ title: 'Plan' },
|
||||
{ title: 'Execute' },
|
||||
{ title: 'Verify' },
|
||||
{ title: 'Generate' },
|
||||
{ title: 'Finalize' },
|
||||
{ title: 'Cleanup' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const MANIFEST_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
plans: { type: 'array', items: { type: 'string' } },
|
||||
taskCount: { type: 'number' },
|
||||
domains: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['plans', 'taskCount', 'domains'],
|
||||
}
|
||||
|
||||
const TASK_LIST_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tasks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
planPath: { type: 'string' },
|
||||
techDomain: { type: 'string' },
|
||||
},
|
||||
required: ['id', 'description', 'planPath', 'techDomain'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['tasks'],
|
||||
}
|
||||
|
||||
const ASSIGNMENT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
devCount: { type: 'number' },
|
||||
devs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
letter: { type: 'string' },
|
||||
scope: { type: 'string' },
|
||||
tasks: { type: 'array', items: { type: 'string' } },
|
||||
outOfScope: { type: 'array', items: { type: 'string' } },
|
||||
techDomain: { type: 'string' },
|
||||
planFiles: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['letter', 'scope', 'tasks', 'outOfScope', 'techDomain', 'planFiles'],
|
||||
},
|
||||
},
|
||||
pmScope: { type: 'string' },
|
||||
},
|
||||
required: ['devCount', 'devs', 'pmScope'],
|
||||
}
|
||||
|
||||
const DEBUG_RESULT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fixed: { type: 'boolean' },
|
||||
summary: { type: 'string' },
|
||||
remainingFailures: { type: 'string' },
|
||||
},
|
||||
required: ['fixed', 'summary'],
|
||||
}
|
||||
|
||||
const VERIFY_RESULT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
allPass: { type: 'boolean' },
|
||||
failures: { type: 'array', items: { type: 'string' } },
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['allPass', 'failures', 'summary'],
|
||||
}
|
||||
|
||||
const WORKTREE_STATUS_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stale: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
branch: { type: 'string' },
|
||||
},
|
||||
required: ['path', 'branch'],
|
||||
},
|
||||
},
|
||||
active: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
branch: { type: 'string' },
|
||||
},
|
||||
required: ['path', 'branch'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['stale', 'active'],
|
||||
}
|
||||
|
||||
const PLAN_STATE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tickedTasks: { type: 'number' },
|
||||
totalTasks: { type: 'number' },
|
||||
gitEvidence: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['tickedTasks', 'totalTasks', 'gitEvidence'],
|
||||
}
|
||||
|
||||
const BRANCH_CHECK_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
collisions: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['collisions'],
|
||||
}
|
||||
|
||||
const VERSION_CHECK_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
consistent: { type: 'boolean' },
|
||||
versions: { type: 'array', items: { type: 'string' } },
|
||||
conflicts: { type: 'array', items: { type: 'string' } },
|
||||
tagExists: { type: 'boolean' },
|
||||
},
|
||||
required: ['consistent', 'versions', 'conflicts', 'tagExists'],
|
||||
}
|
||||
|
||||
const CLEANUP_RESULT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
removed: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
branch: { type: 'string' },
|
||||
},
|
||||
required: ['path', 'branch'],
|
||||
},
|
||||
},
|
||||
kept: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
branch: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['path', 'branch', 'reason'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['removed', 'kept'],
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const REPO = '/home/alee/Sources/relicario'
|
||||
const COORD_DIR = 'docs/superpowers/coordination'
|
||||
|
||||
function devRole(letter) {
|
||||
return 'dev-' + letter.toLowerCase()
|
||||
}
|
||||
|
||||
// ── Routing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const action = (args && args.action) || 'develop'
|
||||
const mode = (args && args.mode) || 'single'
|
||||
const release = args && args.release
|
||||
const context = args && args.context
|
||||
|
||||
// ── ACTION: preflight ─────────────────────────────────────────────────────────
|
||||
|
||||
if (action === 'preflight') {
|
||||
if (!release) throw new Error('args.release is required for action=preflight')
|
||||
|
||||
const [worktrees, baseline, planState, branches] = await parallel([
|
||||
|
||||
() => agent(
|
||||
`Run: git -C ${REPO} worktree list\n` +
|
||||
`Parse the output. For each worktree listed, extract its path and branch.\n` +
|
||||
`Skip the main checkout at ${REPO} itself.\n` +
|
||||
`Then run: git -C ${REPO} branch --merged main\n` +
|
||||
`A worktree is stale if its branch appears in the merged list. Otherwise it is active.\n` +
|
||||
`Return stale (merged worktrees) and active (unmerged worktrees), each as an array of {path, branch}.`,
|
||||
{ schema: WORKTREE_STATUS_SCHEMA, label: 'worktree-scan', phase: 'Discover' }
|
||||
),
|
||||
|
||||
() => agent(
|
||||
`cd ${REPO} and run each of these commands, capturing the last 5 lines of output:\n` +
|
||||
` cargo test --quiet 2>&1 | tail -5\n` +
|
||||
` pnpm --filter extension test --run 2>&1 | tail -5\n` +
|
||||
`Report allPass=true only if both commands exit with code 0. ` +
|
||||
`List any failures with their error messages. Provide a one-line summary.`,
|
||||
{ schema: VERIFY_RESULT_SCHEMA, label: 'baseline-green', phase: 'Discover' }
|
||||
),
|
||||
|
||||
() => agent(
|
||||
`Run: git -C ${REPO} log --oneline --all --grep="${release}" | head -20\n` +
|
||||
`Capture the output as gitEvidence.\n` +
|
||||
`Then scan ${REPO}/docs/superpowers/plans/ for any files whose filename contains "${release}".\n` +
|
||||
`For each matching file, count lines matching "- \\[x\\]" (ticked) and "- \\[ \\]" (unticked).\n` +
|
||||
`Sum across all matching files. Return tickedTasks, totalTasks, and gitEvidence (the git log lines).`,
|
||||
{ schema: PLAN_STATE_SCHEMA, label: 'plan-state', phase: 'Discover' }
|
||||
),
|
||||
|
||||
() => agent(
|
||||
`Run: git -C ${REPO} branch --all\n` +
|
||||
`Return any branch names (local or remote) that contain the string "${release}" as collisions.`,
|
||||
{ schema: BRANCH_CHECK_SCHEMA, label: 'branch-collision', phase: 'Discover' }
|
||||
),
|
||||
|
||||
])
|
||||
|
||||
const issues = []
|
||||
|
||||
if (worktrees.stale.length > 0) {
|
||||
issues.push('orphaned-worktrees')
|
||||
log(`WARN [worktree-scan]: ${worktrees.stale.length} stale worktree(s) found — run action=cleanup to remove them`)
|
||||
for (const w of worktrees.stale) {
|
||||
log(` stale: ${w.path} (${w.branch})`)
|
||||
}
|
||||
} else {
|
||||
log(`[worktree-scan]: clean`)
|
||||
}
|
||||
|
||||
if (!baseline.allPass) {
|
||||
issues.push('baseline-failing')
|
||||
log(`FAIL [baseline-green]: ${baseline.failures.length} failure(s): ${baseline.failures.join(' | ')}`)
|
||||
} else {
|
||||
log(`[baseline-green]: green`)
|
||||
}
|
||||
|
||||
if (planState.tickedTasks > 0) {
|
||||
issues.push('plan-partially-done')
|
||||
log(`WARN [plan-state]: ${planState.tickedTasks}/${planState.totalTasks} tasks already ticked`)
|
||||
for (const e of planState.gitEvidence) {
|
||||
log(` evidence: ${e}`)
|
||||
}
|
||||
} else {
|
||||
log(`[plan-state]: clean slate (0 ticked tasks)`)
|
||||
}
|
||||
|
||||
if (branches.collisions.length > 0) {
|
||||
issues.push('branch-collision')
|
||||
log(`WARN [branch-collision]: branches already exist for release label "${release}": ${branches.collisions.join(', ')}`)
|
||||
} else {
|
||||
log(`[branch-collision]: no collisions`)
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
log(`Preflight PASS`)
|
||||
} else {
|
||||
log(`Preflight has ${issues.length} warning(s): ${issues.join(', ')}`)
|
||||
}
|
||||
|
||||
return { status: issues.length === 0 ? 'pass' : 'warn', issues, worktrees, baseline, planState, branches }
|
||||
}
|
||||
|
||||
// ── ACTION: develop ───────────────────────────────────────────────────────────
|
||||
|
||||
if (action === 'develop') {
|
||||
if (!release) throw new Error('args.release is required for action=develop')
|
||||
|
||||
phase('Discover')
|
||||
const manifest = await agent(
|
||||
`Scan docs/superpowers/plans/ in ${REPO} for plan files belonging to release "${release}". ` +
|
||||
`A plan file belongs if its filename contains the release label, or its opening lines reference it as its target release. ` +
|
||||
`Read each matching file, count checkbox tasks (lines starting with - [ ]), and identify tech domains (rust, extension, docs, etc.). ` +
|
||||
`Return: plans (relative paths from repo root), taskCount, domains.`,
|
||||
{ schema: MANIFEST_SCHEMA, label: 'discover-plans', phase: 'Discover' }
|
||||
)
|
||||
|
||||
log(`Found ${manifest.plans.length} plan(s), ${manifest.taskCount} tasks — domains: ${manifest.domains.join(', ')}`)
|
||||
|
||||
// ── SINGLE MODE ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (mode === 'single') {
|
||||
|
||||
phase('Plan')
|
||||
const taskList = await agent(
|
||||
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
|
||||
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||
`Extract every checkbox task (- [ ] items) and order them to respect dependencies ` +
|
||||
`(e.g. core Rust changes before WASM/CLI consumers, schema changes before UI). ` +
|
||||
`For each task return: id (short slug like S1-step2), description (full step text), ` +
|
||||
`planPath (which file it came from), techDomain (rust/extension/docs/cli/wasm).`,
|
||||
{ schema: TASK_LIST_SCHEMA, label: 'pm-plan', phase: 'Plan' }
|
||||
)
|
||||
|
||||
log(`PM ordered ${taskList.tasks.length} tasks for sequential execution`)
|
||||
|
||||
phase('Execute')
|
||||
await pipeline(
|
||||
taskList.tasks,
|
||||
(task) => agent(
|
||||
`You are a senior developer on the ${release} release of Relicario.\n` +
|
||||
`Repo: ${REPO}\n\n` +
|
||||
`IMPORTANT: cd into ${REPO} before any git or cargo commands.\n\n` +
|
||||
`Your task (${task.id}): ${task.description}\n` +
|
||||
`Plan file for full context: ${task.planPath}\n` +
|
||||
`Tech domain: ${task.techDomain}\n\n` +
|
||||
`Instructions:\n` +
|
||||
`1. Read the plan file for context on this specific step.\n` +
|
||||
`2. Implement ONLY this step — do not run ahead to the next one.\n` +
|
||||
`3. Run the relevant tests after your change (cargo test -p <crate> for Rust; pnpm build for extension).\n` +
|
||||
`4. Commit with a conventional commit message scoped to the change.\n` +
|
||||
`5. Report: what you did, test result (pass/fail), any blockers.`,
|
||||
{ label: task.id, phase: 'Execute' }
|
||||
)
|
||||
)
|
||||
|
||||
// ── Advisory: checkbox hygiene ───────────────────────────────────────────
|
||||
|
||||
await agent(
|
||||
`Read each of these plan files from ${REPO}:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||
`Count any lines still matching "- [ ]" (unticked checkboxes). ` +
|
||||
`Log each unticked item with its file and line text. ` +
|
||||
`This is advisory only — report findings but do not block or fail.`,
|
||||
{ label: 'checkbox-check', phase: 'Verify' }
|
||||
)
|
||||
|
||||
phase('Verify')
|
||||
const verifyResult = await agent(
|
||||
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||
`Commands:\n` +
|
||||
` cargo test\n` +
|
||||
` cargo build --all-targets\n` +
|
||||
` cargo clippy -- -D warnings\n` +
|
||||
`Report pass/fail for each command. List every failure with its error message.`,
|
||||
{ schema: VERIFY_RESULT_SCHEMA, label: 'full-verify', phase: 'Verify' }
|
||||
)
|
||||
|
||||
if (!verifyResult.allPass) {
|
||||
log(`Verify FAILED — ${verifyResult.failures.length} failure(s): ${verifyResult.failures.join(' | ')}`)
|
||||
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
|
||||
return { status: 'verify-failed', failures: verifyResult.failures, summary: verifyResult.summary }
|
||||
}
|
||||
|
||||
// ── Advisory: debug artifact scan ────────────────────────────────────────
|
||||
|
||||
await agent(
|
||||
`Run the following command from ${REPO}:\n` +
|
||||
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
|
||||
`Examine lines beginning with "+" (additions) in the diff output.\n` +
|
||||
`Report any occurrences of:\n` +
|
||||
` - dbg!( in Rust files (warn)\n` +
|
||||
` - console.log( in TypeScript files (warn)\n` +
|
||||
` - TODO or FIXME anywhere (warn)\n` +
|
||||
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
|
||||
`Log each finding with its file and line. This is advisory only — do not block.`,
|
||||
{ label: 'debug-artifact-scan', phase: 'Finalize' }
|
||||
)
|
||||
|
||||
phase('Finalize')
|
||||
await agent(
|
||||
`Update ${REPO}/STATUS.md to reflect the ${release} work that just completed.\n` +
|
||||
`Mark any in-flight items as landed. Set what is now in flight next.\n` +
|
||||
`Commit the STATUS.md update with message "docs: update STATUS for ${release} develop pass".`,
|
||||
{ label: 'update-status', phase: 'Finalize' }
|
||||
)
|
||||
|
||||
log(`Single-mode develop complete. Run action=release when ready to tag.`)
|
||||
return { status: 'complete', mode: 'single', release }
|
||||
}
|
||||
|
||||
// ── MULTI MODE ──────────────────────────────────────────────────────────────
|
||||
|
||||
phase('Plan')
|
||||
const assignment = await agent(
|
||||
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
|
||||
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||
`Decide how many dev streams are needed (one per major domain or plan, max 3). ` +
|
||||
`Minimize cross-dev dependencies. For each dev assign: ` +
|
||||
`letter (A/B/C), scope summary (2 sentences), task IDs they own, ` +
|
||||
`out-of-scope task IDs (owned by other devs), primary techDomain, and which planFiles they need to read. ` +
|
||||
`Also write a 2-sentence pmScope describing your oversight and review duties.`,
|
||||
{ schema: ASSIGNMENT_SCHEMA, label: 'pm-assign', phase: 'Plan' }
|
||||
)
|
||||
|
||||
log(`PM assigned ${assignment.devCount} dev stream(s)`)
|
||||
|
||||
phase('Generate')
|
||||
const allRoles = ['pm', ...assignment.devs.map(d => devRole(d.letter))].join(', ')
|
||||
|
||||
await parallel([
|
||||
|
||||
() => agent(
|
||||
`Write a self-contained PM kickoff prompt to ${REPO}/${COORD_DIR}/${release}-pm-prompt.md.\n\n` +
|
||||
`Release: ${release}\n` +
|
||||
`PM scope: ${assignment.pmScope}\n` +
|
||||
`Plans: ${manifest.plans.join(', ')}\n` +
|
||||
`Dev roster:\n${assignment.devs.map(d => ` Dev-${d.letter}: ${d.scope}`).join('\n')}\n\n` +
|
||||
`The file must include these sections in order:\n` +
|
||||
`1. Role header ("You are the PM for the ${release} release of Relicario.")\n` +
|
||||
`2. Working directory: ${REPO}\n` +
|
||||
`3. Required reading: CLAUDE.md, all plan files listed above\n` +
|
||||
`4. Authority: approve scope changes, review dev PRs, write CHANGELOG entry, drive doc updates, tag release (with user approval only)\n` +
|
||||
`5. Boundaries: write NO feature code; NO destructive ops without user confirmation\n` +
|
||||
`6. Relay server section: localhost:7331, your from="pm", tools: post_message/read_messages/list_pending, recipients: ${allRoles}. Include Python shim fallback.\n` +
|
||||
`7. Dev roster with each dev letter, branch name (feature/${release}-dev-X), worktree path (${REPO}.dev-x), and scope\n` +
|
||||
`8. Coordination protocol: DIRECTIVE block format, RELEASE STATUS rollup format\n` +
|
||||
`9. PR review procedure (gh pr view / gh pr diff)\n` +
|
||||
`10. Pre-tag checklist (all tests pass, CHANGELOG written, STATUS.md updated, all dev PRs merged)\n` +
|
||||
`11. First action: read all required files, emit a RELEASE STATUS block confirming context absorbed, then check all dev inboxes\n` +
|
||||
`Make every section concrete — the receiving Claude has zero prior context.`,
|
||||
{ label: 'gen-pm', phase: 'Generate' }
|
||||
),
|
||||
|
||||
...assignment.devs.map((dev) => () => agent(
|
||||
`Write a self-contained Dev-${dev.letter} kickoff prompt to ${REPO}/${COORD_DIR}/${release}-dev-${dev.letter.toLowerCase()}-prompt.md.\n\n` +
|
||||
`Release: ${release}\n` +
|
||||
`Dev-${dev.letter} scope: ${dev.scope}\n` +
|
||||
`Tasks owned: ${dev.tasks.join(', ')}\n` +
|
||||
`Out of scope: ${dev.outOfScope.join(', ')}\n` +
|
||||
`Tech domain: ${dev.techDomain}\n` +
|
||||
`Plan files: ${dev.planFiles.join(', ')}\n\n` +
|
||||
`The file must include these sections in order:\n` +
|
||||
`1. Role header ("You are Dev-${dev.letter} for the ${release} release of Relicario.")\n` +
|
||||
`2. Worktree setup commands (run these FIRST before anything else):\n` +
|
||||
` git -C ${REPO} worktree add ${REPO}.dev-${dev.letter.toLowerCase()} -b feature/${release}-dev-${dev.letter.toLowerCase()}\n` +
|
||||
` cd ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
|
||||
`3. Working directory after setup: ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
|
||||
`4. CRITICAL subagent rule: every subagent prompt MUST start with "cd ${REPO}.dev-${dev.letter.toLowerCase()} &&" — never rely on working-directory headers alone\n` +
|
||||
`5. Required reading: CLAUDE.md, ${dev.planFiles.join(', ')}\n` +
|
||||
`6. Execution mode: use superpowers:subagent-driven-development\n` +
|
||||
`7. Scope: in-scope tasks (${dev.tasks.join(', ')}), out-of-scope (${dev.outOfScope.join(', ')})\n` +
|
||||
`8. Hard rules from the plan (copy any HIGH-severity or acceptance-test constraints verbatim)\n` +
|
||||
`9. Relay: localhost:7331, your from="${devRole(dev.letter)}", call read_messages before each task, post status/questions to "pm". Recipients: ${allRoles}. Include Python shim fallback.\n` +
|
||||
`10. STATUS UPDATE format: Task / Status (COMPLETE|IN-PROGRESS|BLOCKED) / Notes (what + why) / Next — print locally AND post to pm via relay\n` +
|
||||
`11. Final test commands for ${dev.techDomain}\n` +
|
||||
`12. PR procedure: gh pr create targeting main, title "feat(${release}): Dev-${dev.letter} — <scope>"\n` +
|
||||
`13. First action: run worktree setup, emit STATUS UPDATE "setup complete", start Task 1`,
|
||||
{ label: `gen-dev-${dev.letter.toLowerCase()}`, phase: 'Generate' }
|
||||
)),
|
||||
|
||||
])
|
||||
|
||||
// Check relay, start if needed
|
||||
await agent(
|
||||
`Check if the relay server is running on localhost:7331 by running: ` +
|
||||
`curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1 && echo running || echo stopped\n\n` +
|
||||
`If the output is "stopped", start it: ` +
|
||||
`nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
|
||||
`Then poll curl -sf http://127.0.0.1:7331/sse --max-time 1 once per second for up to 10s. ` +
|
||||
`Report "relay ready" or "relay failed to start (check /tmp/relay-${release}.log)".`,
|
||||
{ label: 'relay-check', phase: 'Generate' }
|
||||
)
|
||||
|
||||
await agent(
|
||||
`Write a bash launch script to ${REPO}/${COORD_DIR}/${release}-launch.sh.\n\n` +
|
||||
`Header comment: # Auto-generated by release workflow — ${release}\n` +
|
||||
`set -e\n\n` +
|
||||
`Section 1 — Relay health check and auto-start:\n` +
|
||||
` if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then\n` +
|
||||
` echo "[relay] already running"\n` +
|
||||
` else\n` +
|
||||
` echo "[relay] starting..." && nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
|
||||
` for i in $(seq 1 10); do sleep 1; curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1 && echo "[relay] ready" && break || true; [ $i -eq 10 ] && echo "[relay] ERROR — check /tmp/relay-${release}.log" && exit 1; done\n` +
|
||||
` fi\n\n` +
|
||||
`Section 2 — tmux session. Session name is the release label.\n` +
|
||||
` If tmux session already exists, attach and exit.\n` +
|
||||
` Otherwise create a new session, then for each role (pm + each dev letter) create a named window\n` +
|
||||
` that runs: claude\n` +
|
||||
` After creating windows, print a prompt-paste cheatsheet showing which file to paste in each window.\n` +
|
||||
` Then attach to the session.\n\n` +
|
||||
`Devs: ${assignment.devs.map(d => 'Dev-' + d.letter).join(', ')}\n` +
|
||||
`Prompt files in ${COORD_DIR}:\n` +
|
||||
` PM: ${release}-pm-prompt.md\n` +
|
||||
assignment.devs.map(d => ` Dev-${d.letter}: ${release}-dev-${d.letter.toLowerCase()}-prompt.md`).join('\\n') + '\\n\\n' +
|
||||
`After writing the file, run: chmod +x ${REPO}/${COORD_DIR}/${release}-launch.sh`,
|
||||
{ label: 'gen-launch-script', phase: 'Generate' }
|
||||
)
|
||||
|
||||
log(`Prompts + launch script ready in ${COORD_DIR}/`)
|
||||
log(`Run: ${REPO}/${COORD_DIR}/${release}-launch.sh`)
|
||||
log(`(starts relay if needed, opens tmux session, prompts you which file to paste in each window)`)
|
||||
|
||||
return { status: 'prompts-ready', devCount: assignment.devCount, coordDir: COORD_DIR }
|
||||
}
|
||||
|
||||
// ── ACTION: debug ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (action === 'debug') {
|
||||
if (!context) throw new Error('args.context required for action=debug — describe the failure or paste test output')
|
||||
|
||||
let currentContext = context
|
||||
const MAX_ITERATIONS = 5
|
||||
|
||||
for (let i = 1; i <= MAX_ITERATIONS; i++) {
|
||||
phase(`Debug iteration ${i}`)
|
||||
|
||||
const result = await agent(
|
||||
`You are debugging a failure in Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
|
||||
`Failure context:\n${currentContext}\n\n` +
|
||||
`Use systematic debugging:\n` +
|
||||
`1. Form a specific hypothesis about the root cause.\n` +
|
||||
`2. Read the relevant source files and tests.\n` +
|
||||
`3. Implement the minimal fix — no unrelated changes.\n` +
|
||||
`4. Run the failing test(s) to confirm they now pass.\n` +
|
||||
`5. Run cargo test to confirm no regressions.\n` +
|
||||
`6. Commit the fix if clean.\n\n` +
|
||||
`Return fixed=true if all tests pass, fixed=false with remainingFailures if not.`,
|
||||
{ schema: DEBUG_RESULT_SCHEMA, label: `debug-iter-${i}` }
|
||||
)
|
||||
|
||||
log(`Iteration ${i}: ${result.summary}`)
|
||||
|
||||
if (result.fixed) {
|
||||
log(`Fixed after ${i} iteration(s).`)
|
||||
return { status: 'fixed', iterations: i, summary: result.summary }
|
||||
}
|
||||
|
||||
currentContext = result.remainingFailures || currentContext
|
||||
log(`Still failing — next iteration with updated context`)
|
||||
}
|
||||
|
||||
log(`Reached max iterations (${MAX_ITERATIONS}). Manual intervention needed.`)
|
||||
return { status: 'max-iterations', lastContext: currentContext }
|
||||
}
|
||||
|
||||
// ── ACTION: verify ────────────────────────────────────────────────────────────
|
||||
|
||||
if (action === 'verify') {
|
||||
phase('Verify')
|
||||
const result = await agent(
|
||||
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||
` cargo test\n` +
|
||||
` cargo build --all-targets\n` +
|
||||
` cargo clippy -- -D warnings\n` +
|
||||
`Report pass/fail for each. List every failure with its error text.`,
|
||||
{ schema: VERIFY_RESULT_SCHEMA, label: 'verify' }
|
||||
)
|
||||
|
||||
if (result.allPass) {
|
||||
log(`All checks pass.`)
|
||||
} else {
|
||||
log(`FAILED: ${result.failures.join(' | ')}`)
|
||||
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ── ACTION: release ───────────────────────────────────────────────────────────
|
||||
|
||||
if (action === 'release') {
|
||||
if (!release) throw new Error('args.release is required for action=release')
|
||||
|
||||
phase('Verify')
|
||||
const verifyResult = await agent(
|
||||
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||
` cargo test\n` +
|
||||
` cargo build --all-targets\n` +
|
||||
` cargo clippy -- -D warnings\n` +
|
||||
`Report pass/fail. List failures.`,
|
||||
{ schema: VERIFY_RESULT_SCHEMA, label: 'pre-release-verify' }
|
||||
)
|
||||
|
||||
if (!verifyResult.allPass) {
|
||||
log(`Tests failing — cannot cut release. Fix first with action=debug.`)
|
||||
return { status: 'blocked', failures: verifyResult.failures }
|
||||
}
|
||||
|
||||
// ── Version + tag checks ─────────────────────────────────────────────────
|
||||
|
||||
const versionCheck = await agent(
|
||||
`Read ${REPO}/Cargo.toml and all files matching ${REPO}/crates/*/Cargo.toml.\n` +
|
||||
`For each file, extract the version field from the [package] section.\n` +
|
||||
`Check whether all extracted versions are identical.\n` +
|
||||
`Then run: git -C ${REPO} tag -l "${release}"\n` +
|
||||
`Set tagExists=true if the output is non-empty (the tag already exists), false otherwise.\n` +
|
||||
`Return consistent (true if all versions match), versions (list of all extracted version strings), ` +
|
||||
`conflicts (list of "file: version" strings for any that differ from the majority), and tagExists.`,
|
||||
{ schema: VERSION_CHECK_SCHEMA, label: 'version-tag-check', phase: 'Finalize' }
|
||||
)
|
||||
|
||||
if (!versionCheck.consistent) {
|
||||
log(`FAIL [version-tag-check]: version mismatch across crates — ${versionCheck.conflicts.join(' | ')}`)
|
||||
return { status: 'blocked', reason: 'version-mismatch' }
|
||||
}
|
||||
|
||||
if (versionCheck.tagExists) {
|
||||
log(`FAIL [version-tag-check]: tag "${release}" already exists — cannot re-tag`)
|
||||
return { status: 'blocked', reason: 'tag-exists' }
|
||||
}
|
||||
|
||||
log(`[version-tag-check]: Versions consistent (${versionCheck.versions[0]}), tag available`)
|
||||
|
||||
// ── Advisory: debug artifact scan ──────────────────────────────────────────
|
||||
|
||||
await agent(
|
||||
`Run the following command from ${REPO}:\n` +
|
||||
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
|
||||
`Examine lines beginning with "+" (additions) in the diff output.\n` +
|
||||
`Report any occurrences of:\n` +
|
||||
` - dbg!( in Rust files (warn)\n` +
|
||||
` - console.log( in TypeScript files (warn)\n` +
|
||||
` - TODO or FIXME anywhere (warn)\n` +
|
||||
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
|
||||
`Log each finding with its file and line. This is advisory only — do not block.`,
|
||||
{ label: 'debug-artifact-scan', phase: 'Finalize' }
|
||||
)
|
||||
|
||||
phase('Finalize')
|
||||
await agent(
|
||||
`Cut the ${release} release for Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
|
||||
`Steps (in order):\n` +
|
||||
`1. Run: git log $(git describe --tags --abbrev=0)..HEAD --oneline\n` +
|
||||
` Use that output to write a ${release} section in CHANGELOG.md — user-facing language, grouped by type.\n` +
|
||||
`2. Update STATUS.md: mark ${release} as released, set what is next.\n` +
|
||||
`3. Update ROADMAP.md: check off the ${release} milestone.\n` +
|
||||
`4. Commit those doc updates: git commit -m "release: ${release}"\n` +
|
||||
`5. Create annotated tag: git tag -a ${release} -m "Release ${release}"\n` +
|
||||
`6. STOP. Print the tag SHA and the push command, then ask the user to confirm before pushing.\n` +
|
||||
` Do NOT run git push or git push --tags without explicit user confirmation.`,
|
||||
{ label: 'cut-release', phase: 'Finalize' }
|
||||
)
|
||||
|
||||
return { status: 'tagged', release, note: 'Confirm and push manually.' }
|
||||
}
|
||||
|
||||
// ── ACTION: cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
if (action === 'cleanup') {
|
||||
phase('Cleanup')
|
||||
|
||||
const result = await agent(
|
||||
`Run: git -C ${REPO} worktree list\n` +
|
||||
`Run: git -C ${REPO} branch --merged main\n\n` +
|
||||
`For each worktree listed (skip the main checkout at ${REPO} itself):\n` +
|
||||
` - If its branch appears in the merged list:\n` +
|
||||
` Run: git -C ${REPO} worktree remove --force <path>\n` +
|
||||
` Run: git -C ${REPO} branch -d <branch> (lowercase -d only, never -D)\n` +
|
||||
` Add to removed: [{path, branch}]\n` +
|
||||
` - If its branch does NOT appear in the merged list:\n` +
|
||||
` Add to kept: [{path, branch, reason: "unmerged"}]\n\n` +
|
||||
`Return removed (worktrees that were deleted) and kept (worktrees that were left in place).`,
|
||||
{ schema: CLEANUP_RESULT_SCHEMA, label: 'cleanup', phase: 'Cleanup' }
|
||||
)
|
||||
|
||||
log(`Cleanup removed ${result.removed.length} worktree(s):`)
|
||||
for (const w of result.removed) {
|
||||
log(` removed: ${w.path} (${w.branch})`)
|
||||
}
|
||||
log(`Cleanup kept ${result.kept.length} worktree(s):`)
|
||||
for (const w of result.kept) {
|
||||
log(` kept: ${w.path} (${w.branch}) — ${w.reason}`)
|
||||
}
|
||||
|
||||
return { status: 'done', removed: result.removed.length, kept: result.kept.length }
|
||||
}
|
||||
|
||||
log(`Unknown action: "${action}". Valid: develop, debug, verify, release, preflight, cleanup`)
|
||||
return { status: 'error', action }
|
||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -1,5 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
## v0.7.0 — 2026-06-01
|
||||
|
||||
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
|
||||
1/2/5 (StateHost typing, SW storage extract, the P2 cluster) shipped
|
||||
2026-05-30; this tag adds the remaining three phases — executed as three
|
||||
parallel worktree streams under PM coordination — which eliminate the
|
||||
two steepest learning cliffs in the extension and close the last
|
||||
`relicario status` CLI/extension parity gap. No crypto, wire-format, or
|
||||
Rust-API changes; this is an internal-architecture + one-feature release.
|
||||
|
||||
### Added
|
||||
|
||||
- **`relicario status` parity in the extension.** New `get_vault_status`
|
||||
service-worker message returns a cached sync summary
|
||||
`{ ahead, behind, lastSyncAt, pendingItems }` with no network call —
|
||||
`ahead`/`behind`/`lastSyncAt` read straight off the cached git-host
|
||||
state (populated by the `sync` handler), `pendingItems` a live count of
|
||||
active (non-trashed) manifest entries. A sidebar-footer status indicator
|
||||
(`vault-status.ts` → `renderStatusIndicator`) renders `N pending` /
|
||||
`N ahead` / `N behind` / `in sync` plus a `last sync …` / `never synced`
|
||||
line, refreshed on mount and on a manual `↻` button — no timer polling,
|
||||
matching the no-network-without-user-intent discipline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Setup wizard crypto moved into the service worker.** The wizard no
|
||||
longer imports `relicario-wasm` or orchestrates the master key directly.
|
||||
New `create_vault` / `attach_vault` SW handlers own the full flow
|
||||
(image-secret embed/extract, unlock, manifest+settings encrypt + push,
|
||||
`register_device` + `addDevice`, persist config + reference image,
|
||||
`session.setCurrent`); on failure the SessionHandle is locked then freed,
|
||||
with ownership transferring only on success. `setup.ts` collapses from
|
||||
~1230 LOC to a 58-LOC UI-only shell; the six render/attach step pairs
|
||||
become a `SetupStep` registry in the new `setup/setup-steps.ts`. Adds
|
||||
`clearWizardState` (bound to `beforeunload` and `goto('mode')`) to wipe
|
||||
sensitive Uint8Array fields when the wizard is abandoned. The
|
||||
non-extension copy-vault-config-JSON escape hatch is preserved.
|
||||
- **`vault.ts` split from a 1037-LOC monolith to 194 LOC of routing +
|
||||
state.** Extracted into five focused modules — `vault-shell` (DOM
|
||||
scaffolding, color-scheme, onMessage wiring), `vault-sidebar` (category
|
||||
nav, 80ms debounced search, bottom nav, status-indicator footer),
|
||||
`vault-list` (list + row rendering), `vault-drawer` (open/close/render +
|
||||
`ensureDrawerClosedForRoute`), `vault-form-wrapper` (wrapped form + sticky
|
||||
bar) — plus two support modules for an acyclic split (`vault-context`,
|
||||
the VaultController contract; `vault-router`, hash routing + pane
|
||||
dispatch).
|
||||
- **`vault_locked` RPC intercept unified.** Lifted out of `vault.ts` into
|
||||
the `sendMessage` wrapper in `shared/state.ts`, so both popup and
|
||||
vault-tab surfaces share one lock-redirect path.
|
||||
- **`state.gitHost` now nulled on explicit lock**, symmetric with the
|
||||
session-timer expiry path, so the new status indicator can't surface a
|
||||
stale `lastSyncAt` after a lock → re-unlock within one service-worker
|
||||
lifetime.
|
||||
|
||||
### Internal
|
||||
|
||||
- Three-stream parallel execution (Dev-A Phase 3, Dev-B Phase 4, Dev-C
|
||||
Phase 6) coordinated via the relay message bus; merges sequenced
|
||||
Phase 3 → 4 → 6 with per-phase done-criteria verification.
|
||||
- Final merged-tree validation: **423/423** vitest (62 files);
|
||||
`npm run build:all` clean for both Chrome and Firefox targets (only the
|
||||
pre-existing ~4 MB WASM asset-size warning). Task 7.1 done-criteria
|
||||
sweep all green. No change to `wasm.d.ts`.
|
||||
|
||||
## v0.6.0 — 2026-05-30
|
||||
|
||||
Rolls up four weeks of post-v0.5.0 work into one tag: the Phase 2B
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -88,7 +88,7 @@ Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
||||
|
||||
## Planning & design specs
|
||||
|
||||
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details.
|
||||
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details. Once a plan exists, execute it via the release workflow (see **Release lifecycle** below) — not directly via subagent-driven-development or executing-plans unless the workflow is unavailable.
|
||||
|
||||
Core references (read before touching crypto, data model, or architecture):
|
||||
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — threat model, entropy analysis, crypto pipeline, crate layout
|
||||
@@ -97,6 +97,31 @@ Core references (read before touching crypto, data model, or architecture):
|
||||
|
||||
After completing any dev iteration, update `STATUS.md` to reflect what shipped and what's now in flight. Update the component doc for any area you changed (see table below).
|
||||
|
||||
## Release lifecycle
|
||||
|
||||
The `release` workflow (`.claude/workflows/release.js`) is the **default execution layer** for all dev work. Invoke it via the `Workflow` tool or the `/release` skill. Full reference: `docs/superpowers/RELEASE-WORKFLOW.md`.
|
||||
|
||||
### Standard actions
|
||||
|
||||
| Action | When | How |
|
||||
|--------|------|-----|
|
||||
| `develop` + `mode:"single"` | Implement a plan; phone/remote; fire-and-forget | `Workflow({name:"release", args:{action:"develop", mode:"single", release:"<label>"}})` |
|
||||
| `develop` + `mode:"multi"` | Parallel streams; at PC; PM supervises devs | `Workflow({name:"release", args:{action:"develop", mode:"multi", release:"<label>"}})` |
|
||||
| `debug` | Fix a failing test or broken feature after manual testing | `Workflow({name:"release", args:{action:"debug", context:"<paste failure>"}})` |
|
||||
| `verify` | Confirm tests pass before releasing | `Workflow({name:"release", args:{action:"verify"}})` |
|
||||
| `release` | Cut and tag a version | `Workflow({name:"release", args:{action:"release", release:"<label>"}})` |
|
||||
|
||||
### Execution defaults
|
||||
|
||||
- **Single-plan work** → `mode:"single"`. One agent works through tasks sequentially; updates `STATUS.md` automatically on completion.
|
||||
- **Multi-plan or multi-phase work** → `mode:"multi"`. PM agent reads plans, assigns dev streams (up to 6), generates prompt files + a `<release>-launch.sh` in `docs/superpowers/coordination/`. Run the launch script — it starts the relay and opens a tmux session.
|
||||
- **Debugging** → always `action:"debug"`. Never hand-fix without at least trying the debug loop first.
|
||||
- **Releasing** → always `action:"release"`. It verifies first, writes CHANGELOG, tags, and stops before push.
|
||||
|
||||
### Multi-agent relay
|
||||
|
||||
The relay server (`tools/relay/`) supports roles `pm`, `dev-a` through `dev-f`. The launch script starts it automatically. If you need to start it manually: `cd tools/relay && ./start.sh`. Protocol reference: `docs/superpowers/coordination/RELAY.md`.
|
||||
|
||||
## Roadmap & status
|
||||
|
||||
Current in-flight work: `STATUS.md`. Full roadmap with release targets: `ROADMAP.md`. Wire format reference: `docs/FORMATS.md`.
|
||||
@@ -129,3 +154,7 @@ Four rules to prevent the kind of drift the 2026-05-30 audits found:
|
||||
4. **Plan-state hygiene.** Plan checkboxes and `STATUS.md`/`ROADMAP.md` must reflect what's actually shipped. Two halves:
|
||||
- **Ship side:** when a commit lands work that maps to a plan task, tick that plan's checkboxes in the same commit (or the immediately-following docs commit). Same for `STATUS.md` — the "Up next" list does not get to lag the actual state of `main` by weeks.
|
||||
- **Execute side:** before starting execution of a plan whose checkboxes are all unchecked, spot-check git log (`git log --oneline --all --grep <distinctive-name>`) or grep for a distinctive symbol/file the plan would create. A plan whose work already merged is the worst kind of plan to re-execute. The 2026-05-30 status-audit found Phase 2B, v0.5.1 Streams A/B/C, and 1C-γ all stealth-shipped two-to-three weeks earlier because nobody ran this check.
|
||||
|
||||
5. **Pre-flight before develop.** Before running `action:"develop"` on any release, run `action:"preflight"` first. If preflight reports FAIL (baseline not green or version mismatch), fix the failure before proceeding. WARN results (orphaned worktrees, partially-done plan) require a judgement call — acknowledge them explicitly before proceeding.
|
||||
|
||||
6. **Cleanup after every lift.** Once all PRs for a release are merged into main, run `Workflow({name:"release", args:{action:"cleanup"}})` to remove the lift's worktrees and feature branches. Stale worktrees accumulate silently and create confusion for the next lift's branch-collision check.
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relicario-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2185,7 +2185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-core"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
@@ -2231,7 +2231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-wasm"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
|
||||
12
ROADMAP.md
12
ROADMAP.md
@@ -7,6 +7,7 @@
|
||||
|
||||
| Version | Highlights |
|
||||
|---|---|
|
||||
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
|
||||
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
|
||||
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
|
||||
|
||||
@@ -14,18 +15,13 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
|
||||
|
||||
## Up next
|
||||
|
||||
All three are specced but have no implementation plan yet. Writing a plan is the first move on any of them.
|
||||
All three 2026-05-04 architecture-review specs are now shipped (CLI restructure = Plan B Cycles 1+2; security polish = Stream A Cycle 1; extension restructure = Plan C Phases 1–6, completed v0.7.0 2026-06-01). The next committed item is:
|
||||
|
||||
- **CLI restructure** — subcommand reorganization, interactive TUI mode
|
||||
Spec: `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
|
||||
- **Extension restructure** — bundle / message-routing cleanup
|
||||
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
||||
- **Security polish** — follow-up hardening from the architecture review
|
||||
Spec: `docs/superpowers/specs/2026-05-04-security-polish-design.md`
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
|
||||
## Medium-term
|
||||
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
_(promote here once specced)_
|
||||
|
||||
## Long-term / backlog
|
||||
|
||||
|
||||
27
STATUS.md
27
STATUS.md
@@ -5,7 +5,7 @@
|
||||
## Version
|
||||
|
||||
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
|
||||
**Active track:** picking the next initiative (CLI restructure / extension restructure / security polish all have specs, no plans yet)
|
||||
**Active track:** **extension restructure (Plan C) — COMPLETE.** All six phases merged. Phases 1, 2, 5 merged 2026-05-30; Phases 3, 4, 6 merged 2026-05-31/06-01 via three parallel worktree streams (Dev-A/B/C under PM coordination). Versions bumped to v0.7.0; tag pending.
|
||||
|
||||
## What landed on main since the v0.5.0 version bump
|
||||
|
||||
@@ -98,6 +98,19 @@ Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md
|
||||
- Item-history-index pane — top-level "items with history" list (`32e1632`)
|
||||
- Sidebar slot wiring + `#history/<id>` route with `#field-history/<id>` legacy normalization (`88d7228`)
|
||||
|
||||
### Extension restructure — Plan C Phases 3, 4, 6 (merged 2026-05-31 → 06-01, v0.7.0)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
||||
Plan: `docs/superpowers/plans/2026-05-30-extension-restructure.md`
|
||||
|
||||
Three parallel worktree streams under PM coordination (relay-bus), completing the restructure begun with Phases 1/2/5:
|
||||
|
||||
- **Phase 3 — setup wizard SW migration + step registry** (Dev-A, merge `9df2fee`). `create_vault` / `attach_vault` SW handlers own the full vault-creation/attach flow (embed/unlock, encrypt+push, register_device+addDevice, persist config+image, `session.setCurrent`; failure path locks+frees the handle). `setup.ts` collapses 1230→58 LOC (UI-only shell, no `relicario-wasm` import); step registry + state + `clearWizardState` + `finishSetup` extracted to new `setup/setup-steps.ts`. `clearWizardState` bound to `beforeunload` + `goto('mode')`. Copy-vault-JSON escape hatch preserved.
|
||||
- **Phase 4 — vault.ts split + vault_locked lift** (Dev-B, merge `3b8368d`). `vault.ts` 1037→194 LOC. Five named modules (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`) plus two support modules (`vault-context` — the VaultController contract; `vault-router` — hash routing + pane dispatch, to hold vault.ts ≤250). `vault_locked` RPC intercept lifted into `shared/state.ts`'s `sendMessage` wrapper. 80ms debounced sidebar search (`SEARCH_DEBOUNCE_MS`); `ensureDrawerClosedForRoute`; `#vault-status-slot` footer staged for Phase 6.
|
||||
- **Phase 6 — get_vault_status + sidebar status indicator** (Dev-C, merge `397cc78`). `get_vault_status` SW handler returns cached `{ahead, behind, lastSyncAt, pendingItems}` with no network call; `vault-status.ts` renders the sidebar-footer indicator (`renderStatusIndicator` into `#vault-status-slot`, refreshed on mount + manual `↻` button, no timer polling). Closes the last `relicario status` CLI/extension parity gap. Also nulls `state.gitHost` on the explicit `lock` handler (symmetric with session-expiry) so the indicator can't show a stale `lastSyncAt`.
|
||||
|
||||
Final merged-tree validation: **423/423 vitest** (62 files), `build:all` clean (only the pre-existing 4MB WASM size warning). Task 7.1 done-criteria sweep: all green.
|
||||
|
||||
### Doc-structure redesign (2026-05-30, complete)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md`
|
||||
@@ -124,10 +137,12 @@ Plan: `docs/superpowers/plans/2026-05-30-doc-structure-redesign.md` (all 37 sub-
|
||||
|
||||
## Up next
|
||||
|
||||
The "Up next" queue at v0.6.0 is the three 2026-05-04 architecture-review specs. Each is specced but has no implementation plan yet — writing a plan is the first move on any of them.
|
||||
Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review specs:
|
||||
|
||||
1. **CLI restructure** (spec `2026-05-04-cli-restructure-design.md`) — subcommand reorganization + interactive TUI mode.
|
||||
2. **Extension restructure** (spec `2026-05-04-extension-restructure-design.md`) — bundle / message-routing cleanup.
|
||||
3. **Security polish** (spec `2026-05-04-security-polish-design.md`) — follow-up security hardening from the architecture review.
|
||||
- **CLI restructure** (`2026-05-04-cli-restructure-design.md`) — *already shipped* as Plan B Cycles 1+2 (`b9bd152`, `3dd1e1b`, `3759f6a`, `e69b347`); the last gap (read-side `refresh_groups_cache` callers in list/get) closed in `d717f0d`. Done-criteria all met.
|
||||
- **Security polish** (`2026-05-04-security-polish-design.md`) — *already shipped* as Stream A Cycle 1 (`89090a8`) plus follow-ups (`0c9387f` start.sh fourth window, `229e483` recovery_qr.rs docs). All four phases done.
|
||||
- **Extension restructure** (`2026-05-04-extension-restructure-design.md`, plan `docs/superpowers/plans/2026-05-30-extension-restructure.md`) — ✅ **COMPLETE** (all six phases merged; see the dated landing section above). Phases 1/2/5 merged 2026-05-30; Phases 3/4/6 merged 2026-05-31 → 06-01. Final tree: 423/423 vitest, build:all clean. v0.7.0 versions bumped; tag pending.
|
||||
|
||||
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.5.0` entry, dated 2026-05-02 — predates the v0.5.1 train work and will be revised when the next tag cuts).
|
||||
Beyond extension restructure, ROADMAP medium-term holds Phase 4 command palette (no spec yet). Long-term: relay server, mobile.
|
||||
|
||||
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers this extension-restructure completion).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -8,7 +8,6 @@ pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ pub fn cmd_list(
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
|
||||
@@ -142,7 +142,13 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||
///
|
||||
/// Visibility note: this is `pub(crate)` so only `session::after_manifest_change`
|
||||
/// can call it. The Plan B Phase 4 done-criterion requires every mutating
|
||||
/// handler to funnel through the wrapper — exposing this helper to commands/
|
||||
/// would let a caller refresh the cache without updating the manifest, breaking
|
||||
/// the invariant.
|
||||
pub(crate) fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
161
docs/superpowers/RELEASE-WORKFLOW.md
Normal file
161
docs/superpowers/RELEASE-WORKFLOW.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Release Workflow
|
||||
|
||||
Unified lifecycle workflow at `.claude/workflows/release.js`.
|
||||
Invoke from any Claude Code session via the `Workflow` tool.
|
||||
|
||||
---
|
||||
|
||||
## Actions at a glance
|
||||
|
||||
| Action | When | Mode |
|
||||
|--------|------|------|
|
||||
| `develop` | Implement plan tasks | `single` (phone/remote) or `multi` (PC, supervised) |
|
||||
| `verify` | Check tests pass | — |
|
||||
| `debug` | Fix a failing test or broken feature | — (always sequential) |
|
||||
| `release` | Cut and tag a version | — |
|
||||
|
||||
---
|
||||
|
||||
## Add features / implement a plan
|
||||
|
||||
### Single-agent (phone-friendly, fire-and-forget)
|
||||
|
||||
One agent works through all plan tasks sequentially. Kick off and check the progress tree later.
|
||||
|
||||
```js
|
||||
Workflow({
|
||||
name: 'release',
|
||||
args: { action: 'develop', mode: 'single', release: 'v0.5.0' }
|
||||
})
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Discovers all plan files matching `v0.5.0`
|
||||
2. PM agent reads plans, orders tasks respecting dependencies
|
||||
3. One dev agent per task runs sequentially
|
||||
4. Full `cargo test` + `cargo build` + `cargo clippy` verify pass
|
||||
5. Updates `STATUS.md`
|
||||
|
||||
### Multi-agent (PC, supervised by PM)
|
||||
|
||||
PM reads the plans, decides N dev streams, writes kickoff prompt files. You open the terminals.
|
||||
|
||||
```js
|
||||
Workflow({
|
||||
name: 'release',
|
||||
args: { action: 'develop', mode: 'multi', release: 'v0.5.0' }
|
||||
})
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Discovers plans
|
||||
2. PM agent assigns tasks to N dev streams
|
||||
3. Generates PM + N dev prompt files in `docs/superpowers/coordination/`
|
||||
4. Prints terminal-open instructions
|
||||
|
||||
**Then you:**
|
||||
```bash
|
||||
cd tools/relay && ./start.sh # start relay server
|
||||
# open N+1 terminal windows
|
||||
# PM window: paste coordination/v0.5.0-pm-prompt.md
|
||||
# Dev-A window: paste coordination/v0.5.0-dev-a-prompt.md
|
||||
# Dev-B window: paste coordination/v0.5.0-dev-b-prompt.md
|
||||
```
|
||||
|
||||
The PM supervises devs in real time via the relay. You watch all terminals.
|
||||
|
||||
---
|
||||
|
||||
## Run tests only
|
||||
|
||||
```js
|
||||
Workflow({ name: 'release', args: { action: 'verify' } })
|
||||
```
|
||||
|
||||
Runs `cargo test`, `cargo build --all-targets`, `cargo clippy`. Returns pass/fail summary.
|
||||
|
||||
---
|
||||
|
||||
## Debug iteration
|
||||
|
||||
After you find a broken test or unexpected behavior, hand the failure context to the debug action. It loops up to 5 times: hypothesize → read code → fix → verify → commit.
|
||||
|
||||
```js
|
||||
Workflow({
|
||||
name: 'release',
|
||||
args: {
|
||||
action: 'debug',
|
||||
context: 'cargo test output:\n...<paste failing test output here>...'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Returns `{ status: "fixed", iterations: N }` when clean, or `{ status: "max-iterations" }` if it needs your eyes.
|
||||
|
||||
---
|
||||
|
||||
## Cut a release
|
||||
|
||||
Runs verify first; blocked if tests fail.
|
||||
Writes CHANGELOG, updates STATUS + ROADMAP, creates annotated tag.
|
||||
**Stops before pushing** — you confirm manually.
|
||||
|
||||
```js
|
||||
Workflow({
|
||||
name: 'release',
|
||||
args: { action: 'release', release: 'v0.5.0' }
|
||||
})
|
||||
```
|
||||
|
||||
After it stops, review the tag then:
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full lifecycle example
|
||||
|
||||
```
|
||||
1. DEVELOP features
|
||||
Workflow({ name:"release", args:{ action:"develop", mode:"single", release:"v0.6.0" } })
|
||||
|
||||
2. VERIFY manually (you run the extension in browser, test your flows)
|
||||
|
||||
3. DEBUG any failures you find
|
||||
Workflow({ name:"release", args:{ action:"debug", context:"<paste failure>" } })
|
||||
# repeat as needed
|
||||
|
||||
4. VERIFY again to confirm clean
|
||||
Workflow({ name:"release", args:{ action:"verify" } })
|
||||
|
||||
5. RELEASE
|
||||
Workflow({ name:"release", args:{ action:"release", release:"v0.6.0" } })
|
||||
# review tag, then: git push && git push --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phone vs PC
|
||||
|
||||
| Scenario | Recipe |
|
||||
|----------|--------|
|
||||
| Kick off a release from your phone / remote session | `develop` + `mode:"single"` — fires in background, check `/workflows` |
|
||||
| At your PC, want to supervise and intervene | `develop` + `mode:"multi"` — generates prompts, open terminals |
|
||||
| Quick sanity check | `verify` |
|
||||
| Fixing a bug you found while testing | `debug` with failure context |
|
||||
| Cutting and tagging | `release` — always confirms before push |
|
||||
|
||||
---
|
||||
|
||||
## Plan file discovery
|
||||
|
||||
The `develop` action scans `docs/superpowers/plans/` for files whose filename or opening lines reference the release label. To be explicit, pass plan paths directly (not yet wired — add `args.plans` if needed).
|
||||
|
||||
---
|
||||
|
||||
## Relay server roles
|
||||
|
||||
The relay at `localhost:7331` supports roles: `pm`, `dev-a`, `dev-b`, `dev-c`.
|
||||
Start it before opening terminal sessions: `cd tools/relay && ./start.sh`
|
||||
See `docs/superpowers/coordination/RELAY.md` for protocol details.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Dev-A Kickoff Prompt — Relicario extension-restructure (Phase 3)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are **Dev-A** for the Relicario extension-restructure release.
|
||||
|
||||
**Goal:** Own Phase 3 in its entirety — migrating the setup wizard's direct WASM orchestration into the service worker as two new SW handlers (`create_vault` and `attach_vault`), then converting the six `renderStepN`/`attachStepN` pairs into the `SetupStep` step-registry pattern and adding `clearWizardState`. This is the largest single phase: seven tasks, heavy orchestration logic, and builds on Phase 1's typed `StateHost` foundation (already shipped).
|
||||
|
||||
**Architecture:** Phase 3 is entirely in the extension. `setup.ts` shrinks from ~1220 LOC to ~500 LOC. No Rust crates, no `relicario-wasm` WASM surface, and no new runtime dependencies are added.
|
||||
|
||||
**Tech Stack:** TypeScript, vitest + happy-dom, webpack.
|
||||
|
||||
---
|
||||
|
||||
## Setup — run these FIRST
|
||||
|
||||
```bash
|
||||
git -C /home/alee/Sources/relicario worktree add /home/alee/Sources/relicario.ext-restructure-a -b feature/extension-restructure-phase-a
|
||||
```
|
||||
|
||||
Then confirm the worktree exists:
|
||||
|
||||
```bash
|
||||
ls /home/alee/Sources/relicario.ext-restructure-a
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.ext-restructure-a`.**
|
||||
|
||||
Every subagent prompt MUST begin with:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.ext-restructure-a &&
|
||||
```
|
||||
|
||||
Never rely on working-directory headers alone — subagents ignore them.
|
||||
|
||||
---
|
||||
|
||||
## Already-shipped context
|
||||
|
||||
- **Phase 1** (typed `StateHost` + `__resetHostForTests`): MERGED to main.
|
||||
- **Phase 2** (SW router helpers extracted to `storage.ts` + `vault.ts`): MERGED to main.
|
||||
- **Phase 5** (5 P2 fixes): MERGED to main.
|
||||
- Baseline: **389/389 vitest tests pass** on main as of the start of this session.
|
||||
- Do NOT re-do any Phase 1, 2, or 5 work. If you find those files already updated, that is expected — proceed.
|
||||
|
||||
---
|
||||
|
||||
## Required reading
|
||||
|
||||
Read these before touching any code:
|
||||
|
||||
1. `/home/alee/Sources/relicario.ext-restructure-a/CLAUDE.md` — project rules (Spanish sprinkle in replies; auto-yes on recommended options; pause before destructive ops)
|
||||
2. `/home/alee/Sources/relicario.ext-restructure-a/docs/superpowers/plans/2026-05-30-extension-restructure.md` — the full plan; Phase 3 is Tasks 3.1-3.7
|
||||
3. `/home/alee/Sources/relicario.ext-restructure-a/extension/ARCHITECTURE.md` — bundle structure, SW↔popup contract, component architecture
|
||||
4. `/home/alee/Sources/relicario.ext-restructure-a/extension/src/setup/setup.ts` — read fully before Task 3.2; the SW handlers must mirror this orchestration exactly
|
||||
|
||||
---
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **`superpowers:subagent-driven-development`**. Spawn a fresh subagent per task. Two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.ext-restructure-a &&`.
|
||||
|
||||
---
|
||||
|
||||
## Scope — own exactly this
|
||||
|
||||
**Phase 3 (Tasks 3.1-3.7):**
|
||||
|
||||
| Task | Summary |
|
||||
|---|---|
|
||||
| 3.1 | Add `create_vault` / `attach_vault` / `get_vault_status` to `messages.ts` |
|
||||
| 3.2 | Implement `create_vault` SW handler in `service-worker/vault.ts` + tests |
|
||||
| 3.3 | Implement `attach_vault` SW handler in `service-worker/vault.ts` + tests |
|
||||
| 3.4 | Delete WASM dynamic-import + `loadWasm` + `verifiedHandle` from `setup.ts` |
|
||||
| 3.5 | Replace WASM calls with `sendMessage(create_vault / attach_vault)` + convert `renderStepN`/`attachStepN` pairs to `SetupStep` step-registry |
|
||||
| 3.6 | Add `clearWizardState()` + `beforeunload` binding + call on `goto('mode')` |
|
||||
| 3.7 | Update setup tests to assert on step-registry shape; add `clearWizardState` test |
|
||||
|
||||
**Out of scope — do not touch:**
|
||||
- Phase 4 (Tasks 4.1-4.7): vault.ts split into 5 focused modules
|
||||
- Phase 6 (Tasks 6.1-6.3): `get_vault_status` parity feature (vault-status.ts + sidebar indicator)
|
||||
|
||||
If you find bugs outside Phase 3 scope, file a `## QUESTION TO PM` block and relay it. Do not fix them yourself.
|
||||
|
||||
---
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Maintain or grow the 389-test baseline.** No vitest regressions. If a task temporarily breaks tests (Tasks 3.4 and 3.5 do — by design, before 3.7 fixes them), track it explicitly and fix before the final commit.
|
||||
- **TDD for new logic.** Write failing tests before implementing `create_vault` and `attach_vault` handlers (Tasks 3.2, 3.3).
|
||||
- **Commit after each logical step.** Per the plan's commit messages: Task 3.1 = one commit; Task 3.2 = one commit; Task 3.3 = one commit; Tasks 3.4-3.7 = one cohesive commit (the plan bundles them because they only compile together).
|
||||
- **Do not merge to main.** The PM owns merges.
|
||||
- **Do not re-use `git amend` on previous commits.** Always create new commits.
|
||||
- **Do not skip hooks (`--no-verify`).**
|
||||
|
||||
---
|
||||
|
||||
## Relay server
|
||||
|
||||
Relay runs at `localhost:7331`. Your identity is `from="dev-a"`.
|
||||
|
||||
Read your inbox with this Python shim (run from any directory):
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
Post to PM:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay && python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
```
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`. Read your inbox before each task. Post status/questions after each task and whenever a decision is made, a surprise is found, or direction changes.
|
||||
|
||||
---
|
||||
|
||||
## STATUS UPDATE format
|
||||
|
||||
Print locally AND relay to `pm` after every task and at each meaningful moment:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601>
|
||||
Task: <N of 7>
|
||||
Status: COMPLETE | IN-PROGRESS | BLOCKED
|
||||
Notes: <what you did + why, 3 sentences max>
|
||||
Next: <next task or "waiting for PM">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Narration discipline
|
||||
|
||||
Emit IN-PROGRESS updates (locally and relayed) at:
|
||||
- Each subagent dispatched
|
||||
- Each significant decision made (e.g., "chose to export `__test__` for test-only access rather than polluting the public API")
|
||||
- Each surprise found (unexpected type error, missing stub, existing test that conflicts)
|
||||
- Any direction change mid-task
|
||||
|
||||
---
|
||||
|
||||
## Task detail reference
|
||||
|
||||
The full task steps (including exact code snippets, grep commands, and commit messages) live in:
|
||||
|
||||
```
|
||||
/home/alee/Sources/relicario.ext-restructure-a/docs/superpowers/plans/2026-05-30-extension-restructure.md
|
||||
```
|
||||
|
||||
Sections: `## Phase 3 — Setup wizard SW migration + step registry (P1.4)` through `### Task 3.7`.
|
||||
|
||||
Key orchestration note for Tasks 3.2 and 3.3: the SW handlers must mirror the exact sequence currently in `setup.ts`. Read `setup.ts` fully before implementing — the plan cannot enumerate every line because `setup.ts` is the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
After all seven tasks are committed, run:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.ext-restructure-a && pnpm --filter extension test && pnpm --filter extension build
|
||||
```
|
||||
|
||||
All 389+ tests must pass. Build must be clean.
|
||||
|
||||
---
|
||||
|
||||
## Pull request
|
||||
|
||||
When tests and build are clean:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --title "feat(extension): restructure Phase 3 (Tasks 3.1-3.7): add create_vault/attach_vault/get_vault_status to messages.ts; implement create_vault SW handler + tests; implement attach_vault SW handler + tests; delete WASM imports/loadWasm/verifiedHandle from setup.ts; replace WASM calls with sendMessage + step-registry conversion; add clearWizardState + beforeunload binding; update setup tests + add clearWizardState test — Dev-A"
|
||||
```
|
||||
|
||||
Return the PR URL in a STATUS UPDATE to PM.
|
||||
|
||||
---
|
||||
|
||||
## First action
|
||||
|
||||
1. Run the worktree setup command above.
|
||||
2. Confirm the worktree path exists.
|
||||
3. Emit a STATUS UPDATE: Task 0 of 7 / Status: COMPLETE / Notes: Worktree created at /home/alee/Sources/relicario.ext-restructure-a on branch feature/extension-restructure-phase-a. / Next: Task 3.1 — add message types.
|
||||
4. Relay that status to pm.
|
||||
5. Read your inbox (`read_messages for="dev-a"`).
|
||||
6. Start Task 3.1.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Dev-B Kickoff Prompt — extension-restructure (Phase 4 + Phase 6)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are **Dev-B** for the Relicario **extension-restructure** release.
|
||||
|
||||
**Goal:** Own Phase 4 and Phase 6 in sequence. Phase 4 splits the 1027-LOC `vault.ts` monolith into five focused modules (`vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`) and lifts the `vault_locked` RPC intercept into `shared/state.ts`, building on the Phase 1 `StateHost` foundation that is already shipped. Phase 6 closes the CLI/extension parity gap by implementing the `get_vault_status` SW handler and wiring the sidebar status indicator — it depends on the `vault-sidebar.ts` module that Phase 4 produces.
|
||||
|
||||
**Architecture:** TypeScript extension only. No Rust crates touched. All new modules live in `extension/src/vault/` (Phase 4) and `extension/src/service-worker/` (Phase 6). The `StateHost` foundation (`shared/state.ts`, typed `PopupState`, `__resetHostForTests`) was shipped in Phase 1 and is already on `main`. Do not redo it.
|
||||
|
||||
**Tech Stack:** TypeScript, vitest + happy-dom, webpack, Rust core via WASM (no new WASM entry points needed).
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Worktree setup (do this FIRST, before anything else)
|
||||
|
||||
```bash
|
||||
git -C /home/alee/Sources/relicario worktree add /home/alee/Sources/relicario.ext-restructure-b -b feature/extension-restructure-phase-b
|
||||
```
|
||||
|
||||
Then all subsequent work happens in `/home/alee/Sources/relicario.ext-restructure-b`.
|
||||
|
||||
**ALL subagent prompts MUST begin with:**
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.ext-restructure-b &&
|
||||
```
|
||||
|
||||
Never rely on working-directory headers alone — subagents may commit to `main` if they do not force-cd into the worktree at prompt start.
|
||||
|
||||
After setup, emit:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Task: setup
|
||||
Status: COMPLETE
|
||||
Notes: Worktree created at /home/alee/Sources/relicario.ext-restructure-b on branch feature/extension-restructure-phase-b. Baseline test count confirmed.
|
||||
Next: Phase 4 Task 4.1
|
||||
```
|
||||
|
||||
Post this update to the relay (see Relay section below).
|
||||
|
||||
---
|
||||
|
||||
## Already-shipped context
|
||||
|
||||
Phases 1, 2, and 5 have been merged to `main`. The following are done — do not redo:
|
||||
|
||||
- `shared/popup-state.ts` — `View` + `PopupState` types extracted
|
||||
- `shared/state.ts` — typed `StateHost` with `registerHost`, `__resetHostForTests`, `sendMessage` wrapper
|
||||
- `shared/__tests__/state.test.ts` — 7 StateHost tests
|
||||
- `service-worker/storage.ts` — `loadDeviceSettings`, `saveDeviceSettings`, `loadBlacklist`, `saveBlacklist`
|
||||
- Phase 5 P2 fixes (inactivity-timer invert, `Promise.allSettled` in devices/trash, MutationObserver debounce, `teardownSettingsCommon`, WASM stub rounding-out)
|
||||
|
||||
**Baseline:** 389/389 vitest tests pass on `main`. You must maintain or grow this count. Never let tests regress.
|
||||
|
||||
---
|
||||
|
||||
## Required reading
|
||||
|
||||
Before writing any code, read:
|
||||
|
||||
1. `CLAUDE.md` — project rules (always applies)
|
||||
2. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — authoritative plan; Phase 4 and Phase 6 task details are defined there
|
||||
3. `extension/ARCHITECTURE.md` — bundle structure, SW message protocol, component architecture
|
||||
4. `extension/src/vault/vault.ts` — the 1027-LOC monolith you will split (read it in full before Task 4.1)
|
||||
5. `extension/src/shared/state.ts` — shipped StateHost contract (Phase 4 lifts `vault_locked` into `sendMessage` here)
|
||||
|
||||
---
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use the **`superpowers:subagent-driven-development`** skill. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.ext-restructure-b &&`.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### Phase 4 — Split `vault.ts` monolith (Tasks 4.1–4.7)
|
||||
|
||||
You own all seven tasks:
|
||||
|
||||
- **Task 4.1** — Extract `vault-shell.ts`: DOM scaffolding, color-scheme apply, `onMessage` wiring
|
||||
- **Task 4.2** — Extract `vault-sidebar.ts`: sidebar categories, debounced search, nav buttons, status slot wiring
|
||||
- **Task 4.3** — Extract `vault-list.ts`: list pane rendering and row rendering
|
||||
- **Task 4.4** — Extract `vault-drawer.ts` + `ensureDrawerClosedForRoute` + `drawer-state.test.ts`
|
||||
- **Task 4.5** — Extract `vault-form-wrapper.ts`: `renderFormWrapped`, sticky bar, header
|
||||
- **Task 4.6** — Trim `vault.ts` to ~200 LOC of routing + state (delete everything extracted above)
|
||||
- **Task 4.7** — Lift `vault_locked` RPC intercept into `shared/state.ts` `sendMessage` + write `state-vault-locked.test.ts`
|
||||
|
||||
### Phase 6 — CLI/extension parity: `get_vault_status` (Tasks 6.1–6.3)
|
||||
|
||||
Phase 6 depends on `vault-sidebar.ts` from Phase 4. Do not start Phase 6 until Phase 4 is complete and all tests pass.
|
||||
|
||||
- **Task 6.1** — Implement `get_vault_status` SW handler in `extension/src/service-worker/vault.ts` + write `vault-status.test.ts`
|
||||
- **Task 6.2** — Create `vault-status.ts` renderer (sidebar-footer status indicator) + write `status-indicator.test.ts`
|
||||
- **Task 6.3** — Wire the status indicator into `vault-sidebar.ts` sidebar footer
|
||||
|
||||
### Out of scope
|
||||
|
||||
Phase 3 (Tasks 3.1–3.7) is owned by another developer. Do NOT touch `setup.ts`, `setup/__tests__/setup.test.ts`, or the SW `create_vault` / `attach_vault` handlers. If you need to coordinate on a shared file, post a question to the relay.
|
||||
|
||||
---
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Maintain or grow the 389-test baseline.** No vitest regressions — ever.
|
||||
- **TDD for all new logic.** Write the failing test first, then the implementation.
|
||||
- **Commit after each task** (not each step — one logical commit per task, bundling its files).
|
||||
- **No `as any` casts.** The typed `StateHost` contract is in place; use it.
|
||||
- **Do not push or open a PR until both phases are complete and the final test run passes.**
|
||||
- **Do not merge to `main`.** The PM owns merges.
|
||||
|
||||
---
|
||||
|
||||
## Relay
|
||||
|
||||
A message-bus server is running at `localhost:7331`. Your identity is `from="dev-b"`.
|
||||
|
||||
**Python shim (use this to call the relay):**
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-b"}'
|
||||
cd /home/alee/Sources/relicario/tools/relay && python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
```
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`.
|
||||
|
||||
**Before each task:** call `read_messages` with `{"for":"dev-b"}` to drain your inbox.
|
||||
|
||||
**After each status update:** call `post_message` to relay your STATUS UPDATE block to `pm`.
|
||||
|
||||
---
|
||||
|
||||
## STATUS UPDATE format
|
||||
|
||||
Use this format for every update — print it locally AND relay it to `pm`:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Task: <task id, e.g. 4.1>
|
||||
Status: COMPLETE | IN-PROGRESS | BLOCKED
|
||||
Notes: <what was done, why the approach was taken, any surprise found — 3 sentences max>
|
||||
Next: <next task id or "waiting for PM">
|
||||
```
|
||||
|
||||
Emit IN-PROGRESS updates at meaningful moments: when a subagent is dispatched, a key architectural decision is made, a surprise is found, or a direction change occurs. Do not wait for phase boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 task details
|
||||
|
||||
Refer to `docs/superpowers/plans/2026-05-30-extension-restructure.md` for the full step-by-step breakdown of each task. The plan is authoritative. Below is a summary of what each task produces to orient you before you read the plan:
|
||||
|
||||
**Task 4.1 — `vault-shell.ts`**
|
||||
Extracts: the `initVaultShell(container)` bootstrapper, `applyColorScheme()`, `document.addEventListener('message', ...)` wiring. `vault.ts` imports `initVaultShell` and calls it at startup.
|
||||
|
||||
**Task 4.2 — `vault-sidebar.ts`**
|
||||
Extracts: `renderSidebar(container, state)`, debounced search input handler, category nav button click wiring, and a `<div class="vault-sidebar__status">` slot at the footer (empty until Phase 6 Task 6.3). Exports `renderSidebar` and `updateSidebarStatus(text: string)`.
|
||||
|
||||
**Task 4.3 — `vault-list.ts`**
|
||||
Extracts: `renderList(container, entries, state)` and `renderRow(entry, state)`. The list pane is a pure render function — no side effects beyond DOM mutation.
|
||||
|
||||
**Task 4.4 — `vault-drawer.ts` + drawer tests**
|
||||
Extracts: `openDrawer(view)`, `closeDrawer()`, `renderDrawerContent(view, state)`, and `ensureDrawerClosedForRoute(route)` (closes the drawer automatically when navigating to list/unlock). Creates `extension/src/vault/__tests__/drawer-state.test.ts` covering the auto-close behavior.
|
||||
|
||||
**Task 4.5 — `vault-form-wrapper.ts`**
|
||||
Extracts: `renderFormWrapped(container, title, renderBody)` — the sticky-header + save-bar scaffold used by add/edit/detail views.
|
||||
|
||||
**Task 4.6 — Trim `vault.ts` to ~200 LOC**
|
||||
After extracting all the above, `vault.ts` should contain only: route dispatch (`handleRoute`), top-level state management (`initVault`, `setState`), and import wiring. Delete the extracted code. Run the full test suite to confirm nothing broke.
|
||||
|
||||
**Task 4.7 — Lift `vault_locked` intercept into `shared/state.ts`**
|
||||
The pre-Phase-4 `vault.ts` has a `vault_locked` channel intercept inside its local `sendMessage` wrapper. Lift this into the `sendMessage` export in `shared/state.ts` (Phase 1 left a placeholder comment there). Write `extension/src/shared/__tests__/state-vault-locked.test.ts` that:
|
||||
- registers a mock host
|
||||
- dispatches a `sendMessage` that returns `{ ok: false, error: 'vault_locked' }`
|
||||
- asserts that `navigate('unlock')` was called on the host
|
||||
- asserts the original rejection is re-thrown (or rethrown as appropriate per the existing intercept logic)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 task details
|
||||
|
||||
Do not start Phase 6 until Phase 4 is fully committed and all 389+ tests pass.
|
||||
|
||||
**Task 6.1 — `get_vault_status` SW handler**
|
||||
Add a `get_vault_status` case to `extension/src/service-worker/vault.ts`. The handler returns:
|
||||
```typescript
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
unlocked: boolean, // whether a session is active
|
||||
vault_dir: string | null, // from cached state.vaultDir
|
||||
git_host: string | null, // from cached state.gitHost
|
||||
item_count: number, // manifest entry count or 0
|
||||
}
|
||||
}
|
||||
```
|
||||
Add `get_vault_status` to `extension/src/shared/messages.ts` as a new `Request` variant.
|
||||
Write `extension/src/service-worker/__tests__/vault-status.test.ts` covering: unlocked path, locked path, and missing-vault path.
|
||||
|
||||
**Task 6.2 — `vault-status.ts` renderer**
|
||||
Create `extension/src/vault/vault-status.ts` with:
|
||||
```typescript
|
||||
export function renderVaultStatus(container: HTMLElement, status: VaultStatusData): void;
|
||||
```
|
||||
The renderer fills `container` with a one-line status indicator: a colored dot + short text (`Unlocked · 42 items` or `Locked` or `No vault`). Write `extension/src/vault/__tests__/status-indicator.test.ts` covering all three states with happy-dom.
|
||||
|
||||
**Task 6.3 — Wire indicator into `vault-sidebar.ts`**
|
||||
At sidebar boot, call `sendMessage({ type: 'get_vault_status' })` and pass the result to `renderVaultStatus(statusSlot, data)`. Re-fetch on every `setState` call so the count stays current. The status slot element (`<div class="vault-sidebar__status">`) was created in Task 4.2.
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
Before opening a PR, run:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.ext-restructure-b && pnpm --filter extension test && pnpm --filter extension build
|
||||
```
|
||||
|
||||
All tests must pass. Build must be clean. Post your final STATUS UPDATE to `pm` with Status: COMPLETE.
|
||||
|
||||
---
|
||||
|
||||
## Opening the PR
|
||||
|
||||
Once both phases are complete and the final run passes:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --title "feat(extension): restructure Phase 4 (Tasks 4.1-4.7): extract vault-shell.ts; extract vault-sidebar.ts with debounced search; extract vault-list.ts; extract vault-drawer.ts + ensureDrawerClosedForRoute + drawer-state tests; extract vault-form-wrapper.ts; trim vault.ts to ~200 LOC routing+state; lift vault_locked intercept into shared/state.ts + state-vault-locked tests+Phase 6 (Tasks 6.1-6.3): implement get_vault_status SW handler + vault-status.test.ts; create vault-status.ts renderer + status-indicator tests; wire indicator into vault-sidebar.ts sidebar footer — Dev-B"
|
||||
```
|
||||
|
||||
Return the PR URL in your final STATUS UPDATE.
|
||||
|
||||
---
|
||||
|
||||
## First action
|
||||
|
||||
1. Run the worktree setup command above.
|
||||
2. Confirm the baseline: `cd /home/alee/Sources/relicario.ext-restructure-b && pnpm --filter extension test 2>&1 | tail -5`
|
||||
3. Emit STATUS UPDATE "setup complete" locally and relay it to `pm`.
|
||||
4. Begin Phase 4 Task 4.1 by reading `extension/src/vault/vault.ts` in full, then dispatching a subagent.
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-generated by release workflow — extension-restructure
|
||||
set -e
|
||||
|
||||
REPO="/home/alee/Sources/relicario"
|
||||
RELAY_DIR="$REPO/tools/relay"
|
||||
COORD="$REPO/docs/superpowers/coordination"
|
||||
RELEASE="extension-restructure"
|
||||
SESSION="$RELEASE"
|
||||
|
||||
# ── 1. Relay ─────────────────────────────────────────────────────────────
|
||||
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
|
||||
echo "[relay] already running on :7331"
|
||||
else
|
||||
echo "[relay] starting..."
|
||||
cd "$RELAY_DIR"
|
||||
nohup npx tsx server.ts > /tmp/relay-extension-restructure.log 2>&1 &
|
||||
for i in $(seq 1 10); do
|
||||
sleep 1
|
||||
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
|
||||
echo "[relay] ready on :7331"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 10 ]; then
|
||||
echo "[relay] ERROR: failed to start — check /tmp/relay-extension-restructure.log"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 2. tmux session ──────────────────────────────────────────────────────
|
||||
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||
echo "[tmux] session '$SESSION' already exists — attaching"
|
||||
exec tmux attach-session -t "$SESSION"
|
||||
fi
|
||||
|
||||
echo "[tmux] creating session '$SESSION'..."
|
||||
tmux new-session -d -s "$SESSION" -n "PM"
|
||||
tmux send-keys -t "$SESSION:PM" "claude" Enter
|
||||
|
||||
tmux new-window -t "$SESSION" -n "Dev-A"
|
||||
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
|
||||
|
||||
tmux new-window -t "$SESSION" -n "Dev-B"
|
||||
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
|
||||
|
||||
tmux select-window -t "$SESSION:PM"
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ extension-restructure — prompt cheatsheet ║"
|
||||
echo "╠══════════════════════════════════════════════════════════════════╣"
|
||||
echo "║ PM window → paste $COORD/$RELEASE-pm-prompt.md ║"
|
||||
echo "║ Dev-A window → paste $COORD/$RELEASE-dev-a-prompt.md ║"
|
||||
echo "║ Dev-B window → paste $COORD/$RELEASE-dev-b-prompt.md ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
|
||||
exec tmux attach-session -t "$SESSION"
|
||||
184
docs/superpowers/coordination/extension-restructure-pm-prompt.md
Normal file
184
docs/superpowers/coordination/extension-restructure-pm-prompt.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# PM Kickoff Prompt — Relicario extension-restructure (Phases 3, 4, 6)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **PM for the Relicario extension-restructure release (Phases 3, 4, 6)**. Two senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all three terminals; a relay server routes messages between you so the user does not need to copy-paste directives.
|
||||
|
||||
## Working directory
|
||||
|
||||
`/home/alee/Sources/relicario`
|
||||
|
||||
Stay on `main` in your own session. Do not check out feature branches. All file reads are against `main`. All doc/CHANGELOG edits happen here too.
|
||||
|
||||
## Required reading (read in this order before acting)
|
||||
|
||||
1. `CLAUDE.md` — project rules. Pay attention to: Spanish flourish in chat replies only, product name capitalization ("Relicario"), "default to yes" autonomy, never run destructive git ops without asking the user.
|
||||
2. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the canonical implementation plan for this release. Phases 3, 4, and 6 are the live work. Phases 1, 2, and 5 are already merged (do not re-do them).
|
||||
3. `extension/ARCHITECTURE.md` — bundle structure, SW ↔ popup message contract, component/pane architecture. Required to review PRs intelligently.
|
||||
|
||||
## Already-shipped context
|
||||
|
||||
Phases 1, 2, and 5 merged into `main` as of commit `8249f9e` (docs update) and `c3f8e35` (Phase 1 merge). The typed `StateHost` foundation (Phase 1) is in `extension/src/shared/state.ts` now. Phase 2 consolidated `storage.ts` and `itemToManifestEntry`. Phase 5 shipped the five P2 fixes (inactivity-timer inversion, `state.gitHost` clear, `teardownSettingsCommon`, `Promise.allSettled`, detector debounce).
|
||||
|
||||
**Current test baseline: 389/389 vitest passing.** This is the floor. Neither dev may land a PR that drops below this.
|
||||
|
||||
Do NOT re-implement any Phase 1, 2, or 5 work. If a dev proposes a change that touches already-shipped territory without a clear regression-fix justification, push back.
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs.
|
||||
- Review PRs: run `gh pr view <n>` and `gh pr diff <n>` before approving.
|
||||
- Write the CHANGELOG entry summarizing what shipped (the extension-restructure section).
|
||||
- Request a tag once all done-criteria pass — **tag requires explicit user approval before you run `git tag`**.
|
||||
- Edit `STATUS.md` and `ROADMAP.md` once all streams land.
|
||||
- Run the final Task 7.1 verification sweep yourself (see Pre-tag checklist below).
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Write NO feature code. Editing `CHANGELOG.md`, `STATUS.md`, `ROADMAP.md`, and coordination docs is fine.
|
||||
- Run NO destructive git operations (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`) without explicit user confirmation.
|
||||
- Do not approve a PR until the dev signals `REVIEW-READY` in the relay.
|
||||
- Do not tag without user approval.
|
||||
- If you are uncertain about a PR's correctness, invoke the `superpowers:requesting-code-review` skill before approving.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus server is running at `localhost:7331`. Three native MCP tools are available in your session:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message. Your `from` is always `"pm"`.
|
||||
- `read_messages(for)` — drain your inbox. Call with `for="pm"`.
|
||||
- `list_pending(for)` — check inbox count without consuming.
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`.
|
||||
|
||||
**Python shim fallback** (use if MCP tools are not registered — this happens when the relay was not running when your session opened):
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
python3 call.py list_pending '{"for":"pm"}'
|
||||
```
|
||||
|
||||
The shim connects over HTTP and has identical semantics to the MCP tools. Narrate what you are doing between tool calls so the user can follow your reasoning.
|
||||
|
||||
## Dev roster
|
||||
|
||||
| Role | Branch | Worktree path | Scope |
|
||||
|---|---|---|---|
|
||||
| Dev-A | `feature/extension-restructure-phase-3` | `/home/alee/Sources/relicario/.worktrees/ext-restructure-phase-3` | Phase 3 entirely: migrate setup wizard's direct WASM orchestration into two new SW handlers (`create_vault`, `attach_vault`); convert the six `renderStepN`/`attachStepN` pairs into the `SetupStep` step-registry pattern; add `clearWizardState`. Tasks 3.1–3.7. Depends on Phase 1's typed `StateHost` (already shipped). |
|
||||
| Dev-B | `feature/extension-restructure-phase-4-6` | `/home/alee/Sources/relicario/.worktrees/ext-restructure-phase-4-6` | Phase 4 then Phase 6 in sequence: Phase 4 splits the 1027-LOC `vault.ts` monolith into five focused modules (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`) and lifts the `vault_locked` RPC intercept into `shared/state.ts`. Tasks 4.1–4.7. Then Phase 6 adds the `get_vault_status` SW handler and wires the sidebar status indicator. Tasks 6.1–6.3. Phase 6 depends on the `vault-sidebar.ts` module that Phase 4 produces — Dev-B must fully merge Phase 4 before starting Phase 6. |
|
||||
|
||||
Both branches fork from the current `main` tip (commit `9fc07c3`).
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
### DIRECTIVE block format
|
||||
|
||||
When you send work instructions to a dev, structure the relay body like this:
|
||||
|
||||
```
|
||||
DIRECTIVE [phase/task]
|
||||
---
|
||||
<concise instruction — what to do, what files to touch, what to verify>
|
||||
DONE SIGNAL: Reply with REVIEW-READY + PR number when complete.
|
||||
```
|
||||
|
||||
### RELEASE STATUS rollup format
|
||||
|
||||
When reporting status to the user (or to yourself at phase boundaries), use:
|
||||
|
||||
```
|
||||
RELEASE STATUS — extension-restructure [date]
|
||||
Phase 3 (Dev-A): [NOT STARTED | IN PROGRESS task N | REVIEW-READY | MERGED]
|
||||
Phase 4 (Dev-B): [NOT STARTED | IN PROGRESS task N | REVIEW-READY | MERGED]
|
||||
Phase 6 (Dev-B): [NOT STARTED — waiting on Phase 4 | IN PROGRESS task N | REVIEW-READY | MERGED]
|
||||
Test baseline: [389 | current count] vitest passing
|
||||
Blockers: [none | describe]
|
||||
Next PM action: [describe]
|
||||
```
|
||||
|
||||
Emit a RELEASE STATUS rollup:
|
||||
- After absorbing required reading (your first action).
|
||||
- Whenever a dev signals `REVIEW-READY`.
|
||||
- After each PR merge.
|
||||
- When a blocker surfaces.
|
||||
|
||||
## Merge-order safety rules (enforce strictly)
|
||||
|
||||
1. **Dev-B must fully merge Phase 4 before starting Phase 6.** `vault-sidebar.ts` is the wiring target for Phase 6's `get_vault_status` status indicator. If Dev-B opens a Phase 6 PR while Phase 4 is still open, reject it.
|
||||
2. **Both devs depend on Phase 1's typed `StateHost` foundation (already on `main` at `c3f8e35`).** If either dev's branch diverges from current `main` before starting, ask them to rebase.
|
||||
3. **Phase 3 and Phase 4 are independent of each other** — they can proceed in parallel. Dev-A and Dev-B may work simultaneously.
|
||||
4. **Do not let either dev touch** `extension/src/wasm.d.ts` unless they have a concrete compilation error that demands it. The plan explicitly states this file is untouched for this release.
|
||||
|
||||
## PR review process
|
||||
|
||||
1. Dev signals `REVIEW-READY` with a PR number in the relay.
|
||||
2. You run `gh pr view <n>` to read the description.
|
||||
3. You run `gh pr diff <n>` to read the diff.
|
||||
4. Check that the PR only touches files in the plan's scope for that phase.
|
||||
5. Check the vitest count in the PR CI (or ask the dev to paste `npx vitest run` output).
|
||||
6. If uncertain about correctness, invoke the `superpowers:requesting-code-review` skill before approving.
|
||||
7. Approve with `gh pr review <n> --approve` and then merge with `gh pr merge <n> --merge`.
|
||||
8. Post `DIRECTIVE` to dev confirming merge and what to do next.
|
||||
|
||||
## Pre-tag checklist (Task 7.1 — you run this yourself)
|
||||
|
||||
Run all of the following from `/home/alee/Sources/relicario/extension` after both Phase 3 and Phase 4+6 PRs are merged:
|
||||
|
||||
```bash
|
||||
# 1. TypeScript clean build
|
||||
npx tsc --noEmit 2>&1 | tail -5
|
||||
# Expected: no output
|
||||
|
||||
# 2. Full vitest suite
|
||||
npx vitest run
|
||||
# Expected: all 389+ tests pass (count must equal or exceed baseline)
|
||||
|
||||
# 3. Production webpack build
|
||||
npm run build:all 2>&1 | tail -5
|
||||
# Expected: both Chrome + Firefox targets compile with no errors
|
||||
# (only the pre-existing 4 MB WASM size warning is acceptable)
|
||||
```
|
||||
|
||||
Then run the done-criteria checklist from the plan's Task 7.1 (lines 2549–2597 of `docs/superpowers/plans/2026-05-30-extension-restructure.md`). Key grep checks:
|
||||
|
||||
```bash
|
||||
# No as-any in shared/state.ts public surface
|
||||
grep -c ": any\|<any>" extension/src/shared/state.ts
|
||||
|
||||
# Router files have no duplicated storage helpers
|
||||
grep -c "function loadDeviceSettings\|function loadBlacklist\|function saveBlacklist" extension/src/service-worker/router/*.ts
|
||||
|
||||
# setup.ts does not import relicario-wasm directly
|
||||
grep -c "relicario-wasm" extension/src/setup/setup.ts
|
||||
|
||||
# SW handles all three new messages
|
||||
grep -c "case 'create_vault'\|case 'attach_vault'\|case 'get_vault_status'" extension/src/service-worker/router/popup-only.ts
|
||||
|
||||
# vault.ts does not contain the vault_locked intercept
|
||||
grep -c "vault_locked" extension/src/vault/vault.ts
|
||||
|
||||
# Sidebar search is debounced
|
||||
grep "SEARCH_DEBOUNCE_MS" extension/src/vault/vault-sidebar.ts
|
||||
```
|
||||
|
||||
All of the above must pass. If any check fails, send the dev a DIRECTIVE to fix it before tagging.
|
||||
|
||||
Once all checks pass:
|
||||
1. Write the CHANGELOG entry (under a new `## [Unreleased]` or the appropriate version header).
|
||||
2. Update `STATUS.md`: move extension-restructure from in-flight to shipped.
|
||||
3. Update `ROADMAP.md`: advance the pointer to whatever comes next.
|
||||
4. Commit those docs: `git add CHANGELOG.md STATUS.md ROADMAP.md && git commit -m "docs: extension-restructure (Phases 3+4+6) complete; update STATUS/ROADMAP/CHANGELOG"`
|
||||
5. **Ask the user for approval before tagging.**
|
||||
|
||||
## Your first action
|
||||
|
||||
Do these steps in order:
|
||||
|
||||
1. Read `CLAUDE.md`, then `docs/superpowers/plans/2026-05-30-extension-restructure.md`, then `extension/ARCHITECTURE.md`.
|
||||
2. Emit a RELEASE STATUS block confirming you have absorbed the context (include the current main tip commit hash from `git log --oneline -1`).
|
||||
3. Drain your relay inbox: `read_messages(for="pm")` — note any pending messages from devs.
|
||||
4. Send a DIRECTIVE to Dev-A kicking off Phase 3, and a DIRECTIVE to Dev-B kicking off Phase 4. Both can start in parallel. Remind Dev-B that Phase 6 must wait until Phase 4 is fully merged.
|
||||
174
docs/superpowers/coordination/v0.7-dev-a-prompt.md
Normal file
174
docs/superpowers/coordination/v0.7-dev-a-prompt.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Dev A Kickoff Prompt — v0.7.0 Plan A (Phase 3)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan A for the v0.7.0 "finish the extension restructure" release.
|
||||
|
||||
Your plan is **Phase 3 — Setup wizard SW migration + step registry** (Tasks 3.1–3.7) of the extension restructure. You move all setup-wizard crypto orchestration out of `setup.ts` and into the service worker behind three new messages (`create_vault`, `attach_vault`, `get_vault_status`), collapse the six `renderStepN`/`attachStepN` pairs into a `SetupStep` registry, and add `clearWizardState()`. `setup.ts` drops from ~1220 LOC to ≤500 and no longer imports `relicario-wasm`. This is the biggest single phase (effort: L). Phase 1 (the typed `StateHost` foundation you depend on) is already merged.
|
||||
|
||||
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard -b phase-c-3-setup-wizard
|
||||
cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
|
||||
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard`** before any other instruction — otherwise the subagent may commit to main.
|
||||
|
||||
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
**Common pitfalls (avoid):**
|
||||
|
||||
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 3 / P1.4 only**)
|
||||
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 3, Tasks 3.1–3.7**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Phase 3 Tasks 3.1–3.7 — `messages.ts` additions (`create_vault`, `attach_vault`, `get_vault_status` request shapes + response interfaces + `POPUP_ONLY_TYPES`), `create_vault` + `attach_vault` SW handlers in `service-worker/vault.ts`, dispatch wiring in `service-worker/router/popup-only.ts`, WASM-stub round-out, deletion of WASM orchestration from `setup.ts`, the `SetupStep` step registry, `clearWizardState`, and the setup test updates.
|
||||
|
||||
**Out of scope:** Phase 4 (Dev-B owns `vault.ts` split + `vault_locked` lift) and Phase 6 (Dev-C owns the `get_vault_status` *handler*, *renderer*, and *sidebar wiring*). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- **You own `extension/src/shared/messages.ts` for this release.** Task 3.1 adds all three new request types — including `get_vault_status`, which Dev-C (Phase 6) will *consume* but not redefine. Land Task 3.1 early so Dev-C is unblocked; tell the PM the moment it's committed/merged so they can clear Dev-C.
|
||||
- You add the `create_vault` and `attach_vault` *handlers* to `service-worker/vault.ts`; Dev-C adds the `get_vault_status` handler to the same file. Coordinate via PM — your Phase 3 should merge before Dev-C's SW handler to minimize conflict on the import block / dispatch switch.
|
||||
- The crypto orchestration body (embed_image_secret → unlock → register_device → manifest_encrypt for create; extract_image_secret → unlock → register_device for attach) must be copied from the *existing* `setup.ts` flow verbatim — do not invent new steps. `setup.ts` is the source of truth for the exact sequence.
|
||||
- Follow Plan A's `.free()` policy: every `SessionHandle.free()` must be preceded by `wasm.lock(handle)`. The handler's `finally` block locks-then-frees only if it still owns the handle.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task. The `Notes` field narrates WHAT happened and WHY — not just "Phase X done". Three sentences max; quality over length. Print every STATUS UPDATE locally before/after sending it.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||
Branch: phase-c-3-setup-wizard
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act.
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you write similar code twice, extract.
|
||||
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious.
|
||||
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||
|
||||
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging (don't fudge — debug); a discovered bug not in your plan; anything destructive; before opening the PR for review.
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||
```
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin phase-c-3-setup-wizard
|
||||
gh pr create --base main --head phase-c-3-setup-wizard --title "feat(ext): Plan C Phase 3 — setup wizard SW migration + step registry" --body "$(cat <<'EOF'
|
||||
## Plan C Phase 3 — Setup wizard SW migration + step registry
|
||||
|
||||
Part of v0.7.0 (finish the extension restructure). Implements Phase 3 (Tasks 3.1–3.7) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`.
|
||||
|
||||
### What changed
|
||||
- `shared/messages.ts`: added `create_vault`, `attach_vault`, `get_vault_status` request shapes + response interfaces; +3 to `POPUP_ONLY_TYPES`.
|
||||
- `service-worker/vault.ts`: `handleCreateVault` + `handleAttachVault` (SW now owns the crypto orchestration lifted from setup.ts).
|
||||
- `service-worker/router/popup-only.ts`: dispatch cases for the new messages.
|
||||
- `setup/setup.ts`: dropped direct WASM orchestration + `loadWasm` + `verifiedHandle`; six `renderStepN`/`attachStepN` pairs collapsed into the `SetupStep` registry; added `clearWizardState()` bound to `beforeunload` + `goto('mode')`. ~1220 LOC → ≤500.
|
||||
- Tests: `service-worker/__tests__/vault.test.ts`, updated `setup/__tests__/setup.test.ts` (step-registry shape + clearWizardState).
|
||||
|
||||
### Coordination notes
|
||||
- This PR owns the only `messages.ts` change for the release; Dev-C's Phase 6 consumes `get_vault_status` (defined here) without re-declaring it.
|
||||
- Merge before Dev-C's Phase 6 SW handler to keep the `service-worker/vault.ts` import block / dispatch switch conflict-free.
|
||||
|
||||
### Verification
|
||||
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||
- Done-criteria greps from the plan's Task 7.1 pass (`setup.ts` ≤500 LOC, no `relicario-wasm` import, 3 dispatch cases, `clearWizardState` bound).
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-3-setup-wizard`). Then — because you own `messages.ts` which Dev-C needs — prioritize Task 3.1 and tell the PM the moment it lands. Then continue with Task 3.2.
|
||||
173
docs/superpowers/coordination/v0.7-dev-b-prompt.md
Normal file
173
docs/superpowers/coordination/v0.7-dev-b-prompt.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Dev B Kickoff Prompt — v0.7.0 Plan B (Phase 4)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan B for the v0.7.0 "finish the extension restructure" release.
|
||||
|
||||
Your plan is **Phase 4 — Split `vault.ts` + lift `vault_locked` channel** (Tasks 4.1–4.7) of the extension restructure. You split the 1037-LOC `vault.ts` monolith into 5 focused modules — `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts` — trimming `vault.ts` to ≤~250 LOC of routing + state, add the debounced sidebar search, and lift the `vault_locked` RPC intercept out of `vault.ts` into `shared/state.ts`'s `sendMessage` wrapper (whose signature Phase 1 already laid). Effort: M. Phase 1 (the typed `StateHost` foundation) is already merged.
|
||||
|
||||
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split -b phase-c-4-vault-split
|
||||
cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
|
||||
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split`** before any other instruction — otherwise the subagent may commit to main.
|
||||
|
||||
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
**Common pitfalls (avoid):**
|
||||
|
||||
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 4 / P1.5 only**)
|
||||
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 4, Tasks 4.1–4.7**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Phase 4 Tasks 4.1–4.7 — create `vault-shell.ts`, `vault-sidebar.ts` (with the 80ms debounced search per DEV-C P2), `vault-list.ts`, `vault-drawer.ts` (incl. `ensureDrawerClosedForRoute` + drawer auto-close on non-list nav), `vault-form-wrapper.ts`; trim `vault.ts` to routing + state ≤~250 LOC; remove the `vault_locked` intercept from `vault.ts` and fill the body of `shared/state.ts`'s `sendMessage` wrapper with it; the drawer-state + (any vault) tests.
|
||||
|
||||
**Out of scope:** Phase 3 (Dev-A owns `setup.ts` + `messages.ts` + the `create_vault`/`attach_vault` SW handlers) and Phase 6 (Dev-C owns `get_vault_status` + the `vault-status.ts` renderer + its sidebar-footer wiring). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- **You create `extension/src/vault/vault-sidebar.ts`. Dev-C (Phase 6, Task 6.3) will later modify it to wire the status indicator into the sidebar footer.** To make that handoff clean, when you build `vault-sidebar.ts`, include a clearly-labelled footer slot in the sidebar markup (an empty `<div id="vault-status-slot"></div>` inside a `vault-sidebar__footer` element is fine) even though you don't populate it — leave a one-line comment that Phase 6 wires it. Tell the PM the moment Phase 4 is REVIEW-READY/merged so Dev-C can start Task 6.3.
|
||||
- The `vault_locked` intercept logic is *moved*, not rewritten: lift the exact behavior from `vault.ts` (the pre-Phase-4 RPC intercept) into `sendMessage` in `shared/state.ts`. After the move, `grep -c "vault_locked" extension/src/vault/vault.ts` must return 0.
|
||||
- Each module extraction is a no-behavior-change refactor — run `npx vitest run` after each and keep it green. Paste function bodies verbatim from `vault.ts`; don't redesign them.
|
||||
- Do not touch `shared/messages.ts` — that's Dev-A's file for this release. If you think you need a message change, escalate to PM.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task. The `Notes` field narrates WHAT happened and WHY. Three sentences max; quality over length. Print every STATUS UPDATE locally before/after sending it.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")` first, then post via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||
Branch: phase-c-4-vault-split
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act.
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
|
||||
- Do not create parallel implementations of an existing helper. If you write similar code twice, extract.
|
||||
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious.
|
||||
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||
|
||||
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; before opening the PR for review.
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||
```
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin phase-c-4-vault-split
|
||||
gh pr create --base main --head phase-c-4-vault-split --title "refactor(ext): Plan C Phase 4 — split vault.ts + lift vault_locked channel" --body "$(cat <<'EOF'
|
||||
## Plan C Phase 4 — Split vault.ts + lift vault_locked channel
|
||||
|
||||
Part of v0.7.0 (finish the extension restructure). Implements Phase 4 (Tasks 4.1–4.7) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`.
|
||||
|
||||
### What changed
|
||||
- Split the 1037-LOC `vault/vault.ts` into 5 modules: `vault-shell.ts` (DOM scaffolding + color-scheme + onMessage), `vault-sidebar.ts` (categories nav + 80ms debounced search + bottom nav + footer status slot), `vault-list.ts` (list/row rendering), `vault-drawer.ts` (open/close/render + `ensureDrawerClosedForRoute`), `vault-form-wrapper.ts` (`renderFormWrapped` + sticky bar + header).
|
||||
- `vault.ts` trimmed to ≤~250 LOC of routing + state.
|
||||
- Lifted the `vault_locked` RPC intercept out of `vault.ts` into `shared/state.ts`'s `sendMessage` wrapper (Phase 1 laid the signature; this fills the body).
|
||||
- Tests: `vault/__tests__/drawer-state.test.ts` (drawer auto-close on navigation) + state `vault_locked` channel coverage.
|
||||
|
||||
### Coordination notes
|
||||
- `vault-sidebar.ts` ships with an empty footer status slot (`#vault-status-slot`); Dev-C's Phase 6 Task 6.3 wires the indicator into it. Merge this PR before Dev-C's wiring commit.
|
||||
- No `messages.ts` changes (that's Dev-A's file this release).
|
||||
|
||||
### Verification
|
||||
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||
- Done-criteria greps from the plan's Task 7.1 pass (5 `vault-*.ts` modules, `vault.ts` ≤~250 LOC, `vault_locked` count 0 in vault.ts, `SEARCH_DEBOUNCE_MS` present).
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-4-vault-split`), then start Task 4.1. Remember to leave the footer status slot in `vault-sidebar.ts` for Dev-C, and ping the PM when you're REVIEW-READY so Dev-C can begin Task 6.3.
|
||||
178
docs/superpowers/coordination/v0.7-dev-c-prompt.md
Normal file
178
docs/superpowers/coordination/v0.7-dev-c-prompt.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Dev C Kickoff Prompt — v0.7.0 Plan C (Phase 6)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan C for the v0.7.0 "finish the extension restructure" release.
|
||||
|
||||
Your plan is **Phase 6 — `get_vault_status` SW handler + sidebar status indicator** (Tasks 6.1–6.3) of the extension restructure. You add the `get_vault_status` service-worker handler (returning cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` plus a live `pendingItems` count — no network call), build the `vault-status.ts` renderer for the sidebar-footer indicator, and wire it into the sidebar (refresh on mount + a manual ↻ button, **no timer polling**). This closes the last `relicario status` CLI/extension parity gap. Effort: S-M.
|
||||
|
||||
**⚠️ Your phase has cross-stream dependencies — read the coordination rules carefully.** Phase 6 depends on Phase 3 (Dev-A) for the `get_vault_status` message type and on Phase 4 (Dev-B) for the `vault-sidebar.ts` module you wire into.
|
||||
|
||||
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status -b phase-c-6-vault-status
|
||||
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`** before any other instruction — otherwise the subagent may commit to main.
|
||||
|
||||
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-c"}'
|
||||
```
|
||||
|
||||
**Common pitfalls (avoid):**
|
||||
|
||||
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 6 only**)
|
||||
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 6, Tasks 6.1–6.3**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Phase 6 Tasks 6.1–6.3 — `handleGetVaultStatus` in `service-worker/vault.ts` + cached `ahead`/`behind`/`lastSyncAt` fields on the git-host state + populating them in the `sync` handler + dispatch wiring in `popup-only.ts`; the `vault-status.ts` renderer + any new glyphs in `shared/glyphs.ts`; wiring the indicator into `vault-sidebar.ts`'s footer (mount + manual refresh). Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
|
||||
|
||||
**Out of scope:** Phase 3 (Dev-A owns `setup.ts`, ALL of `messages.ts`, and the `create_vault`/`attach_vault` handlers) and Phase 4 (Dev-B owns the `vault.ts` split, including *creating* `vault-sidebar.ts`). You only *modify* `vault-sidebar.ts` to add the wiring in Task 6.3. If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules — sequencing (this is the crux of your phase):**
|
||||
|
||||
- **Do NOT touch `shared/messages.ts`.** Dev-A (Phase 3, Task 3.1) defines the `get_vault_status` request type + `GetVaultStatusResponse` interface. You *import* `GetVaultStatusResponse` from `../shared/messages`; you never declare it. **Before you can compile Task 6.1, Dev-A's Task 3.1 must have landed on main** (or be available to merge). Confirm with the PM at kickoff. If it hasn't landed, ask the PM whether to wait or to proceed against a temporary local type and reconcile at merge — prefer waiting if Dev-A is close.
|
||||
- **Stage your tasks 6.1 → 6.2 → 6.3.** Tasks 6.1 (SW handler) and 6.2 (renderer) are independent of Phase 4 and you can build them as soon as the `get_vault_status` type exists. **Task 6.3 wires into `vault-sidebar.ts`, which Dev-B (Phase 4) creates — you MUST wait for Dev-B's Phase 4 PR to merge before doing Task 6.3.** Ask the PM to confirm Phase 4 is merged, then pull main into your branch and do the wiring. Dev-B has been told to leave an empty `#vault-status-slot` footer element for you.
|
||||
- Your `get_vault_status` handler is additive in `service-worker/vault.ts` alongside Dev-A's `create_vault`/`attach_vault` handlers. Expect a possible small merge conflict on the import block / dispatch switch in `service-worker/vault.ts` + `popup-only.ts`; the PM will sequence your SW handler merge after Dev-A's Phase 3.
|
||||
- **No network in `get_vault_status`** — return cached state only. The spec is explicit: sync is user-initiated. **No timer polling** in the wiring — refresh on mount + manual ↻ button only.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task, **and especially when you are blocked waiting on Dev-A's or Dev-B's merge** (so the PM knows your idle is a dependency wait, not a stall). The `Notes` field narrates WHAT happened and WHY. Three sentences max. Print every STATUS UPDATE locally before/after sending it.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||
Branch: phase-c-6-vault-status
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task** (e.g. "is Phase 3's `get_vault_status` type merged yet?" / "is Phase 4 merged so I can do 6.3?"): post via `post_message(kind="question")` with format:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-C` blocks from the PM via relay. Acknowledge and act.
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
|
||||
- Do not create parallel implementations of an existing helper (reuse `shared/relative-time.ts` for the timestamp; reuse the existing glyph family in `shared/glyphs.ts`).
|
||||
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious.
|
||||
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||
|
||||
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; **the dependency waits (Phase 3 type / Phase 4 sidebar)**; before opening the PR for review.
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||
```
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin phase-c-6-vault-status
|
||||
gh pr create --base main --head phase-c-6-vault-status --title "feat(ext): Plan C Phase 6 — get_vault_status + sidebar status indicator" --body "$(cat <<'EOF'
|
||||
## Plan C Phase 6 — get_vault_status + sidebar status indicator
|
||||
|
||||
Part of v0.7.0 (finish the extension restructure). Implements Phase 6 (Tasks 6.1–6.3) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`. Closes the `relicario status` CLI/extension parity gap.
|
||||
|
||||
### What changed
|
||||
- `service-worker/vault.ts`: `handleGetVaultStatus` — returns cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` + live `pendingItems` from the manifest. No network call.
|
||||
- `service-worker/git-host.ts`: cached `lastSyncAt`/`ahead`/`behind` fields, populated by the `sync` handler.
|
||||
- `service-worker/router/popup-only.ts`: `get_vault_status` dispatch case.
|
||||
- `vault/vault-status.ts`: sidebar-footer indicator renderer (in sync / N ahead / N behind / N pending / never synced); reuses `shared/relative-time.ts` + glyph family.
|
||||
- `vault/vault-sidebar.ts`: wired the indicator into the footer slot — refresh on mount + manual ↻ button, no timer polling.
|
||||
- Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
|
||||
|
||||
### Coordination notes
|
||||
- Consumes the `get_vault_status` message type defined by Dev-A's Phase 3 (`messages.ts`); does not redefine it.
|
||||
- Task 6.3 wiring lands on top of Dev-B's Phase 4 `vault-sidebar.ts` (merged first).
|
||||
|
||||
### Verification
|
||||
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||
- Done-criteria greps from the plan's Task 7.1 pass (`get_vault_status` dispatched + rendered, no network in handler, no polling timer).
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-6-vault-status`). Then immediately ask the PM (via `## QUESTION TO PM`) whether Dev-A's Phase 3 `get_vault_status` type has landed yet — that gates Task 6.1. While you wait, you can prepare the Task 6.2 renderer (`vault-status.ts`) since it only needs the local `VaultStatus` shape, not `messages.ts`.
|
||||
68
docs/superpowers/coordination/v0.7-launch.sh
Executable file
68
docs/superpowers/coordination/v0.7-launch.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-generated by multi-agent-kickoff — v0.7.0 (finish the extension restructure)
|
||||
# Streams: Dev-A = Phase 3, Dev-B = Phase 4, Dev-C = Phase 6
|
||||
set -e
|
||||
|
||||
REPO="/home/alee/Sources/relicario"
|
||||
RELAY_DIR="$REPO/tools/relay"
|
||||
COORD="$REPO/docs/superpowers/coordination"
|
||||
RELEASE="v0.7"
|
||||
SESSION="$RELEASE"
|
||||
|
||||
# ── 1. Relay ─────────────────────────────────────────────────────────────
|
||||
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
|
||||
echo "[relay] already running on :7331"
|
||||
else
|
||||
echo "[relay] starting..."
|
||||
cd "$RELAY_DIR"
|
||||
nohup npx tsx server.ts > /tmp/relay-v0.7.log 2>&1 &
|
||||
for i in $(seq 1 10); do
|
||||
sleep 1
|
||||
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
|
||||
echo "[relay] ready on :7331"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 10 ]; then
|
||||
echo "[relay] ERROR: failed to start — check /tmp/relay-v0.7.log"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 2. tmux session ──────────────────────────────────────────────────────
|
||||
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||
echo "[tmux] session '$SESSION' already exists — attaching"
|
||||
exec tmux attach-session -t "$SESSION"
|
||||
fi
|
||||
|
||||
echo "[tmux] creating session '$SESSION'..."
|
||||
tmux new-session -d -s "$SESSION" -n "PM"
|
||||
tmux send-keys -t "$SESSION:PM" "claude" Enter
|
||||
|
||||
tmux new-window -t "$SESSION" -n "Dev-A"
|
||||
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
|
||||
|
||||
tmux new-window -t "$SESSION" -n "Dev-B"
|
||||
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
|
||||
|
||||
tmux new-window -t "$SESSION" -n "Dev-C"
|
||||
tmux send-keys -t "$SESSION:Dev-C" "claude" Enter
|
||||
|
||||
tmux select-window -t "$SESSION:PM"
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ v0.7.0 — finish the extension restructure — prompt cheatsheet ║"
|
||||
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||
echo "║ PM window → paste $COORD/v0.7-pm-prompt.md ║"
|
||||
echo "║ Dev-A window → paste $COORD/v0.7-dev-a-prompt.md ║"
|
||||
echo "║ Dev-B window → paste $COORD/v0.7-dev-b-prompt.md ║"
|
||||
echo "║ Dev-C window → paste $COORD/v0.7-dev-c-prompt.md ║"
|
||||
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||
echo "║ A = Phase 3 (setup wizard SW migration) ║"
|
||||
echo "║ B = Phase 4 (split vault.ts + vault_locked lift) ║"
|
||||
echo "║ C = Phase 6 (get_vault_status + status indicator) — deps on A & B ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
|
||||
exec tmux attach-session -t "$SESSION"
|
||||
129
docs/superpowers/coordination/v0.7-pm-prompt.md
Normal file
129
docs/superpowers/coordination/v0.7-pm-prompt.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# PM Kickoff Prompt — v0.7.0 finish the extension restructure
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the v0.7.0 "finish the extension restructure" release. 3 senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all 3+1 terminals and relays messages between them.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
```
|
||||
The shim connects over HTTP and has the same semantics as the MCP tools.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — the bundle spec
|
||||
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the implementation plan. **Phases 1, 2, 5 already merged (2026-05-30).** This release finishes the remaining three:
|
||||
- Plan A (Dev-A) → **Phase 3** (Tasks 3.1–3.7): setup wizard SW migration + step registry + `clearWizardState`
|
||||
- Plan B (Dev-B) → **Phase 4** (Tasks 4.1–4.7): split `vault.ts` into 5 modules + lift the `vault_locked` channel into `shared/state.ts`
|
||||
- Plan C (Dev-C) → **Phase 6** (Tasks 6.1–6.3): `get_vault_status` SW handler + sidebar status indicator
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from each dev's feature branch
|
||||
- Drive any release-prep work that isn't a feature plan (Task 7.1 final verification sweep, CHANGELOG, version bumps to v0.7.0, STATUS.md / ROADMAP.md updates) — this is your hands-on work
|
||||
- Tag `v0.7.0` once everything is integrated **— but only after explicit user approval**
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` / STATUS / ROADMAP are fine.
|
||||
- Don't deviate from the spec without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag without user approval.
|
||||
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
|
||||
|
||||
## ⚠️ Critical: cross-stream dependencies (the whole reason you exist this release)
|
||||
|
||||
Per the plan's "Notes on execution order": **Phase 4 blocks Phase 6, and Phase 3 owns a file Phase 6 needs.** Your central job is sequencing the merges and arbitrating the two shared edits:
|
||||
|
||||
1. **`extension/src/shared/messages.ts`** — Dev-A (Phase 3, Task 3.1) adds all three new request types: `create_vault`, `attach_vault`, **and `get_vault_status`**, plus their response interfaces, plus the three additions to `POPUP_ONLY_TYPES`. Dev-C (Phase 6) *consumes* `get_vault_status` but must NOT redefine it. **Directive at kickoff:** Dev-A owns every `messages.ts` change; Dev-C imports `GetVaultStatusResponse` from `messages.ts` and does not touch that file. If Dev-C starts before Dev-A's Task 3.1 lands, have Dev-C either (a) wait on the type, or (b) work against a local type alias and you reconcile at merge — prefer (a) if Dev-A is close.
|
||||
|
||||
2. **`extension/src/vault/vault-sidebar.ts`** — Dev-B (Phase 4, Task 4.2) *creates* this file. Dev-C (Phase 6, Task 6.3) *modifies* it to wire the status indicator into the sidebar footer. **Directive:** Dev-C should land Tasks 6.1 (SW handler) and 6.2 (renderer `vault-status.ts`) — both independent of Phase 4 — first, then HOLD on Task 6.3 until Dev-B's Phase 4 PR merges. Sequence the merges: **Phase 4 merges before Phase 6's wiring commit.**
|
||||
|
||||
3. **`extension/src/service-worker/vault.ts`** — Dev-A (Phase 3: `create_vault` / `attach_vault` handlers) and Dev-C (Phase 6: `get_vault_status` handler) both append handlers here, and both add a dispatch case to `service-worker/router/popup-only.ts`. These are additive and shouldn't conflict, but you may get a small merge conflict on the import block / switch statement. Merge Dev-A (Phase 3) before Dev-C's SW handler if possible to minimize churn. A trivial conflict here is expected — resolve it at merge or have the second dev rebase.
|
||||
|
||||
**Recommended merge order:** Phase 3 (Dev-A) → Phase 4 (Dev-B) → Phase 6 (Dev-C). Confirm this with the devs at kickoff so Dev-C knows to stage 6.1/6.2 early and 6.3 last.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of 3+1 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action. If the relay MCP tools are not registered in your session, fall back to the Python shim (see **Relay server** section above) or ask the user to relay manually.
|
||||
|
||||
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is the PM terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks, either from the relay inbox or relayed by the user if the relay is down.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-<letter>
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user at any time, give a current rollup:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — v0.7.0
|
||||
Devs: <per-dev one-line state>
|
||||
PM: <what you're working on>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Dev A REVIEW-READY", "tag v0.7.0">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check the diff against the spec and plan acceptance criteria (the plan's "Final Verification" Task 7.1 lists the exact done-criteria greps — use them)
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash — project rule: git history is the audit log)
|
||||
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
|
||||
|
||||
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
|
||||
|
||||
## Pre-tag checklist
|
||||
|
||||
Before tagging `v0.7.0`:
|
||||
|
||||
- [ ] Every dev branch merged to main (Phases 3, 4, 6)
|
||||
- [ ] Task 7.1 done-criteria sweep passes (all greps in the plan's Final Verification section)
|
||||
- [ ] `cd extension && npx tsc --noEmit` clean
|
||||
- [ ] `cd extension && npx vitest run` green (baseline was 389/389 + new Phase 3/4/6 tests)
|
||||
- [ ] `cd extension && npm run build:all` clean (only the pre-existing 4MB WASM warning)
|
||||
- [ ] `cargo test` still green (these phases don't touch Rust, but confirm no accidental breakage)
|
||||
- [ ] STATUS.md + ROADMAP.md moved extension restructure to "Shipped"; CHANGELOG.md v0.7.0 entry written; version bumped to v0.7.0
|
||||
- [ ] User-driven smoke test of the merged result
|
||||
- [ ] Explicit user approval to tag
|
||||
|
||||
After all PRs merge, run the project's cleanup (CLAUDE.md rule #6): `Workflow({name:"release", args:{action:"cleanup"}})` to remove this lift's worktrees and branches. **Note:** there are also stale `phase-c-1/2/5` worktrees from the previous lift (under `.worktrees/`) that were never cleaned up — flag this to the user; they may want them removed too (destructive op → ask first).
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec, the plan, and the cross-stream dependency map above.
|
||||
3. Send opening directives to all three devs via `post_message` — at minimum: (a) confirm Dev-A owns ALL of `messages.ts`, (b) confirm the merge order Phase 3 → Phase 4 → Phase 6, (c) tell Dev-C to stage Tasks 6.1/6.2 first and HOLD 6.3 until Phase 4 merges.
|
||||
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing them to proceed.
|
||||
2660
docs/superpowers/plans/2026-05-30-extension-restructure.md
Normal file
2660
docs/superpowers/plans/2026-05-30-extension-restructure.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@ Firefox build (the vault tab is Chrome-only for the moment). Verify in
|
||||
| `service-worker` | `src/service-worker/index.ts` | extension SW / bg | yes — initialized lazily on first message |
|
||||
| `popup` | `src/popup/popup.ts` | popup.html | no — goes through SW |
|
||||
| `vault` | `src/vault/vault.ts` (Chrome only) | vault.html (tab) | no — goes through SW |
|
||||
| `setup` | `src/setup/setup.ts` | setup.html (tab) | yes — direct dynamic import (predates SW handle) |
|
||||
| `setup` | `src/setup/setup.ts` | setup.html (tab) | no — goes through SW (`create_vault`/`attach_vault`) |
|
||||
| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no |
|
||||
|
||||
### What each bundle owns
|
||||
@@ -183,17 +183,51 @@ before any new render.
|
||||
|
||||
### `src/vault/`
|
||||
|
||||
- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`,
|
||||
`#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`,
|
||||
`#history`, `#history/<id>`, `#backup`, `#import`). Legacy
|
||||
`#field-history/<id>` URLs are normalized to `#history/<id>` on
|
||||
`parseHash` (`vault.ts:139-173`); the internal view value stays
|
||||
`'field-history'` so the per-item pane renders unchanged. Sidebar
|
||||
bottom-nav: `+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history
|
||||
· ⏻ lock`. Registers itself as the StateHost so all
|
||||
`popup/components/*` renderers run unchanged. Maintains its own
|
||||
`selectedItem` cache so hash navigation between already-loaded items
|
||||
doesn't refetch.
|
||||
- `vault.ts` (194 lines) — fullscreen tab entry, now a thin
|
||||
routing + state shell after the Phase 4 split. Registers itself as
|
||||
the StateHost so all `popup/components/*` renderers run unchanged,
|
||||
maintains its own `selectedItem` cache so hash navigation between
|
||||
already-loaded items doesn't refetch, and delegates DOM scaffolding,
|
||||
navigation, list/drawer/form rendering, and route dispatch to the
|
||||
sibling modules below. The hash-route set is
|
||||
`#detail/<id>`, `#add/<type>`, `#trash`, `#devices`, `#settings`,
|
||||
`#settings-vault`, `#history`, `#history/<id>`, `#backup`, `#import`.
|
||||
- `vault-context.ts` — the `VaultController` contract plus the shared
|
||||
types and pure helpers the split modules depend on. Added so the
|
||||
split is acyclic: the rendering modules import the controller
|
||||
interface from here rather than from `vault.ts`.
|
||||
- `vault-router.ts` — hash routing + pane dispatch + data loading,
|
||||
extracted to keep `vault.ts` ≤250 LOC. Owns `parseHash`; legacy
|
||||
`#field-history/<id>` URLs are normalized to `#history/<id>` here, but
|
||||
the internal view value stays `'field-history'` so the per-item pane
|
||||
renders unchanged.
|
||||
- `vault-shell.ts` — DOM scaffolding, color-scheme apply, and the
|
||||
`onMessage` wiring for the tab.
|
||||
- `vault-sidebar.ts` — sidebar categories nav, 80ms-debounced search
|
||||
(`SEARCH_DEBOUNCE_MS`), and the bottom-nav
|
||||
(`+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock`).
|
||||
Also owns the footer: a `#vault-status-slot` plus a manual `↻` refresh
|
||||
button (`GLYPH_REFRESH`). `wireSidebar` calls `refreshStatus()` once on
|
||||
mount and again on the button's click — sending `get_vault_status` via
|
||||
`ctx.sendMessage` and rendering the result into the slot through
|
||||
`vault-status.ts`. There is **no timer polling**: the indicator only
|
||||
refreshes on mount + explicit button press, matching the spec's
|
||||
no-network-without-user-intent discipline (sync is user-initiated).
|
||||
- `vault-status.ts` — sidebar-footer sync indicator renderer.
|
||||
`renderStatusIndicator(el, status)` is pure DOM: it renders, by
|
||||
priority, `N pending` / `N ahead` / `N behind`, falling back to
|
||||
`in sync`, plus a `last sync <relativeTime>` / `never synced` line.
|
||||
Reuses `shared/glyphs.ts` (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`)
|
||||
and `shared/relative-time.ts`. `VaultStatus` is an alias of
|
||||
`GetVaultStatusResponse['data']`, so the renderer's input shape is
|
||||
single-sourced from the message contract and can't drift from the SW
|
||||
handler.
|
||||
- `vault-list.ts` — the list pane and its row rendering.
|
||||
- `vault-drawer.ts` — drawer open/close/render plus
|
||||
`ensureDrawerClosedForRoute`, which closes the drawer on any
|
||||
non-list navigation.
|
||||
- `vault-form-wrapper.ts` — `renderFormWrapped` plus the sticky bar and
|
||||
header that wrap form panes.
|
||||
- `vault.html` / `vault.css` — sidebar + pane layout.
|
||||
|
||||
### `src/vault/components/`
|
||||
@@ -211,12 +245,19 @@ exports `render…(app)` and a `teardown()`, same convention as
|
||||
|
||||
### `src/setup/`
|
||||
|
||||
- `setup.ts` (1137 lines) — the wizard state machine. Six steps
|
||||
(0..5): mode picker (new vault / attach this device), host type
|
||||
(Gitea/GitHub), host config + connection test + repo probe, the
|
||||
forking step 3 (create-vault vs attach-this-device), device name,
|
||||
finish. Loads WASM directly. State-coupled `updateStrengthUi` stays
|
||||
here because it walks the live wizard state.
|
||||
- `setup.ts` (58 lines) — a thin UI-only shell after the Phase 3
|
||||
split: the render loop + progress track + boot + re-exports. No longer
|
||||
imports `relicario-wasm`; the wizard now drives vault creation/attach
|
||||
through the SW. Binds `clearWizardState` to
|
||||
`window.addEventListener('beforeunload', clearWizardState)`
|
||||
(`setup.ts:53`) and also calls it on `goto('mode')` (`setup.ts:44`).
|
||||
- `setup-steps.ts` (extracted in Phase 3) — the setup step registry +
|
||||
wizard state + `clearWizardState` + `finishSetup`. One-directional
|
||||
import (`setup.ts` → `setup-steps.ts`, no cycle). Crypto orchestration
|
||||
no longer lives in the wizard: the device step (where `deviceName`
|
||||
exists) fires `create_vault` and `attach_vault` SW messages instead of
|
||||
calling WASM directly. State-coupled `updateStrengthUi` stays here
|
||||
because it walks the live wizard state.
|
||||
- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure
|
||||
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
|
||||
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
|
||||
@@ -273,7 +314,23 @@ exports `render…(app)` and a `teardown()`, same convention as
|
||||
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
|
||||
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
|
||||
with its own captured-tab verification (see Key flows). New in
|
||||
commit `a7dbf35`: `register_this_device`.
|
||||
commit `a7dbf35`: `register_this_device`. Phase 3 added
|
||||
`create_vault` and `attach_vault` (full SW-side vault
|
||||
creation/attach: embed/unlock, encrypt+push, `register_device` +
|
||||
`addDevice`, persist config+image, `session.setCurrent`; the failure
|
||||
path locks and frees the handle). The `lock` handler now also nulls
|
||||
`state.gitHost` (symmetric with session-expiry) so the status cache
|
||||
can't go stale across a lock→unlock. Phase 6 added `get_vault_status`
|
||||
(popup-only, read-only) — returns the cached sync summary
|
||||
`{ ahead, behind, lastSyncAt, pendingItems }` with **no network
|
||||
call**. `ahead`/`behind`/`lastSyncAt` are read straight off
|
||||
`state.gitHost` (populated by the `sync` handler, which records
|
||||
`lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after
|
||||
a successful manifest fetch). `pendingItems` is a live count of active
|
||||
(non-trashed) manifest entries via `vault.listItems(manifest).length`.
|
||||
`ahead`/`behind` are structurally always `0` in the extension (it
|
||||
writes straight to the host via the Contents REST API; there is no
|
||||
local commit graph) and exist for parity with `relicario status`.
|
||||
- `router/content-callable.ts` — handler match arms for every
|
||||
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
|
||||
`sender.tab.url`, never from message fields. `capture_save_login`
|
||||
@@ -287,7 +344,13 @@ exports `render…(app)` and a `teardown()`, same convention as
|
||||
no www-stripping, no public-suffix), trash helpers
|
||||
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
|
||||
attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`,
|
||||
with manifest summary sync).
|
||||
with manifest summary sync). Now also includes the
|
||||
`create_vault`/`attach_vault` orchestration handlers (Phase 3) and
|
||||
`handleGetVaultStatus(state)` (Phase 6) — synchronous, no network;
|
||||
returns the cached `{ ahead, behind, lastSyncAt, pendingItems }`. Its
|
||||
`Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks
|
||||
the `PopupState` import cycle and structurally forbids it from making
|
||||
a network call.
|
||||
- `session.ts` — single module-scope `SessionHandle | null`. α assumes
|
||||
one vault per install. Multi-vault would replace this with a `Map`
|
||||
keyed by vault id.
|
||||
@@ -301,6 +364,15 @@ exports `render…(app)` and a `teardown()`, same convention as
|
||||
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
|
||||
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
|
||||
attachment writes switch from the Contents API to the Git Data API.
|
||||
The `GitHost` interface also carries cached sync metadata —
|
||||
`lastSyncAt: number | null` (unix seconds), `ahead: number`,
|
||||
`behind: number` — initialized to `null`/`0`/`0` in both `GiteaHost`
|
||||
and `GitHubHost`. The cache rides the gitHost lifecycle: created on
|
||||
unlock and cleared whenever `state.gitHost` is nulled — on
|
||||
session-timer expiry (`index.ts`) **and** on the explicit `lock`
|
||||
message handler (`popup-only.ts`), which now nulls `state.gitHost`
|
||||
symmetrically so a lock→unlock cycle can't surface a stale
|
||||
`lastSyncAt`.
|
||||
- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use
|
||||
the host's Contents API for files under threshold, and Git Data API
|
||||
(blobs + tree + commit) for large attachment uploads. Auth differs
|
||||
@@ -322,7 +394,9 @@ exports `render…(app)` and a `teardown()`, same convention as
|
||||
- `state.ts` — `StateHost` interface + module-scope singleton. Both
|
||||
`popup.ts` and `vault.ts` register themselves on boot. All
|
||||
`popup/components/*` import from here, never from popup.ts directly,
|
||||
so the same render code runs in both bundles.
|
||||
so the same render code runs in both bundles. Its `sendMessage`
|
||||
wrapper intercepts `vault_locked` responses (lifted out of `vault.ts`
|
||||
in Phase 4, so the intercept now applies uniformly to both bundles).
|
||||
- `types.ts` — TypeScript mirrors of the Rust core's serde shapes:
|
||||
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
|
||||
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Relicario",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Relicario",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "relicario-extension",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
|
||||
@@ -93,9 +93,21 @@ setupFillListener();
|
||||
scan();
|
||||
|
||||
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
|
||||
const observer = new MutationObserver(() => {
|
||||
scan();
|
||||
});
|
||||
// Plan C Phase 5: SPA churn fires the MutationObserver many times per
|
||||
// second. Trailing-edge debounce coalesces bursts so we run the full
|
||||
// scan() at most once per quiet 200ms window.
|
||||
const SCAN_DEBOUNCE_MS = 200;
|
||||
let scanTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function scheduleScan(): void {
|
||||
if (scanTimer !== undefined) clearTimeout(scanTimer);
|
||||
scanTimer = setTimeout(() => {
|
||||
scanTimer = undefined;
|
||||
scan();
|
||||
}, SCAN_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(scheduleScan);
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
|
||||
@@ -95,6 +95,32 @@ describe('devices view', () => {
|
||||
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Plan C Phase 5 — defensive Promise.allSettled:
|
||||
// a rejected secondary feed (list_revoked) should not kill the whole render.
|
||||
it('renders devices when revoked list fails (load-error slot shown)', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
// Primary list still rendered.
|
||||
expect(app.innerHTML).toContain('CLI');
|
||||
// Inline fallback slot present.
|
||||
expect(app.innerHTML).toContain("Couldn't load revoked devices");
|
||||
});
|
||||
|
||||
it('renders devices when revoked list returns {ok:false}', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
|
||||
.mockResolvedValueOnce({ ok: false, error: 'list_revoked_failed' });
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
expect(app.innerHTML).toContain('CLI');
|
||||
expect(app.innerHTML).toContain("Couldn't load revoked devices");
|
||||
});
|
||||
|
||||
it('confirming register sends register_this_device with the entered name', async () => {
|
||||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||
// Initial render: list_devices + list_revoked.
|
||||
|
||||
@@ -31,35 +31,64 @@ export function teardown(): void {
|
||||
// No cleanup needed
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-C P2: defensive per-slot rendering. The active list is the primary
|
||||
* feed — if it fails entirely, we still surface an error page. The
|
||||
* revoked list is secondary — its failure renders an inline "couldn't
|
||||
* load" slot but doesn't kill the page.
|
||||
*/
|
||||
function revokedLoadErrorHtml(): string {
|
||||
return `
|
||||
<details class="revoked-section">
|
||||
<summary class="muted">▸ revoked devices</summary>
|
||||
<div class="revoked-section__body">
|
||||
<p class="muted">Couldn't load revoked devices.</p>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
// Get current device name from local storage
|
||||
const stored = await chrome.storage.local.get(['device_name']);
|
||||
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
||||
|
||||
// Fetch active device list and revoked list in parallel
|
||||
const [devicesResp, revokedResp] = await Promise.all([
|
||||
// Fetch active device list and revoked list in parallel. allSettled so a
|
||||
// rejected secondary feed doesn't kill the whole render.
|
||||
const [devicesSettled, revokedSettled] = await Promise.allSettled([
|
||||
sendMessage({ type: 'list_devices' }),
|
||||
sendMessage({ type: 'list_revoked' }),
|
||||
]);
|
||||
|
||||
if (!devicesResp.ok) {
|
||||
if (devicesSettled.status === 'rejected' || !devicesSettled.value.ok) {
|
||||
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||
const revokedDevices: RevokedEntry[] = revokedResp.ok
|
||||
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
|
||||
// devicesSettled.value.ok is true here (guarded above), so .data is present.
|
||||
const devicesData = (devicesSettled.value as { ok: true; data: unknown }).data;
|
||||
const devices = (devicesData as { devices: Device[] }).devices;
|
||||
const revokedOk = revokedSettled.status === 'fulfilled' && revokedSettled.value.ok;
|
||||
const revokedDevices: RevokedEntry[] = revokedOk
|
||||
? ((revokedSettled.value as { ok: true; data: unknown }).data as { revoked: RevokedEntry[] }).revoked
|
||||
: [];
|
||||
|
||||
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||||
|
||||
// Precompute fingerprints for all active devices
|
||||
// Precompute fingerprints for all active devices. allSettled so one bad
|
||||
// public key doesn't kill the whole list — fall back to '(unknown)'.
|
||||
const fingerprints = new Map<string, string>();
|
||||
await Promise.all(devices.map(async (d) => {
|
||||
const fp = await sshFingerprint(d.public_key);
|
||||
fingerprints.set(d.name, fp ?? '(unknown)');
|
||||
}));
|
||||
const fpResults = await Promise.allSettled(
|
||||
devices.map((d) => sshFingerprint(d.public_key).then((fp) => [d.name, fp] as const)),
|
||||
);
|
||||
for (let i = 0; i < devices.length; i += 1) {
|
||||
const r = fpResults[i];
|
||||
if (r.status === 'fulfilled' && r.value[1]) {
|
||||
fingerprints.set(r.value[0], r.value[1]);
|
||||
} else {
|
||||
fingerprints.set(devices[i].name, '(unknown)');
|
||||
}
|
||||
}
|
||||
|
||||
const activeDevicesHtml = devices.length === 0
|
||||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||
@@ -82,7 +111,9 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
|
||||
const revokedSectionHtml = !revokedOk
|
||||
? revokedLoadErrorHtml()
|
||||
: revokedDevices.length === 0 ? '' : `
|
||||
<details class="revoked-section">
|
||||
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
||||
<div class="revoked-section__body">
|
||||
@@ -117,7 +148,7 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
` : ''}
|
||||
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
||||
${activeDevicesHtml}
|
||||
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
|
||||
${!revokedOk ? `<div class="section-header">REVOKED · ?</div>` : (revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : '')}
|
||||
${revokedSectionHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { SessionTimeoutConfig } from '../../shared/messages';
|
||||
import { relativeTime } from '../../shared/relative-time';
|
||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||
import { teardownSettingsCommon } from './settings';
|
||||
import { GLYPH_NEXT } from '../../shared/glyphs';
|
||||
|
||||
let pendingSettings: VaultSettings | null = null;
|
||||
@@ -17,11 +18,7 @@ let pendingSession: SessionTimeoutConfig | null = null;
|
||||
let baseSession: SessionTimeoutConfig | null = null;
|
||||
|
||||
export function teardown(): void {
|
||||
closeGeneratorPanel();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
|
||||
pendingSettings = null;
|
||||
pendingSession = null;
|
||||
baseSession = null;
|
||||
|
||||
@@ -53,13 +53,29 @@ export async function renderSettings(container: HTMLElement): Promise<void> {
|
||||
await renderSection(activeSection);
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
/**
|
||||
* Common cleanup invoked by both the device-settings teardown
|
||||
* (settings.ts) and the vault-settings teardown (settings-vault.ts).
|
||||
* Centralized to avoid the "regression class with known prior leaks"
|
||||
* DEV-C P2 flagged.
|
||||
*
|
||||
* Closes the generator popover and detaches the supplied keydown
|
||||
* handler from the document if present. Returns the new handler value
|
||||
* (always null), so the caller can do `handler = teardownSettingsCommon(handler)`.
|
||||
*/
|
||||
export function teardownSettingsCommon(
|
||||
keyHandler: ((e: KeyboardEvent) => void) | null,
|
||||
): null {
|
||||
closeGeneratorPanel();
|
||||
teardownSecuritySection();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
if (keyHandler) {
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
|
||||
teardownSecuritySection();
|
||||
pendingVaultSettings = null;
|
||||
sessionHandle = null;
|
||||
}
|
||||
|
||||
@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
// Captured tab snapshot taken at popup-open. Used by fill_credentials
|
||||
// to guard against TOCTOU navigation — the SW re-checks this URL's
|
||||
// hostname against the tab's live URL before forwarding fill_credentials
|
||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
newType: import('../shared/types').ItemType | null;
|
||||
vaultSettings: import('../shared/types').VaultSettings | null;
|
||||
generatorDefaults: import('../shared/types').GeneratorRequest | null;
|
||||
historyItemId: import('../shared/types').ItemId | null;
|
||||
}
|
||||
import type { View, PopupState } from '../shared/popup-state';
|
||||
|
||||
let currentState: PopupState = {
|
||||
view: 'locked',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as timer from '../session-timer';
|
||||
import { READ_ONLY_CONTENT_CALLABLE } from '../session-timer';
|
||||
|
||||
describe('session-timer', () => {
|
||||
beforeEach(() => {
|
||||
@@ -97,3 +98,29 @@ describe('session-timer', () => {
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('READ_ONLY_CONTENT_CALLABLE — inversion exclusion set', () => {
|
||||
// The SW handler invokes resetTimer() on every message whose type is NOT
|
||||
// in this set. These cases encode the documented inversion contract from
|
||||
// Plan C Phase 5: popup-only messages reset, content-callable writes
|
||||
// reset, only passive content reads (currently just get_autofill_candidates)
|
||||
// do NOT reset.
|
||||
|
||||
it('popup-only message would reset the timer (not in exclusion set)', () => {
|
||||
// e.g. list_items — popup interaction is unambiguously active use
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('list_items')).toBe(false);
|
||||
});
|
||||
|
||||
it('content-callable get_autofill_candidates does NOT reset (in exclusion set)', () => {
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('get_autofill_candidates')).toBe(true);
|
||||
});
|
||||
|
||||
it('content-callable capture_save_login DOES reset (write op = active use)', () => {
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('capture_save_login')).toBe(false);
|
||||
});
|
||||
|
||||
it('content-callable check_credential DOES reset', () => {
|
||||
// Asking "is this credential already saved" is user-initiated.
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('check_credential')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
56
extension/src/service-worker/__tests__/storage.test.ts
Normal file
56
extension/src/service-worker/__tests__/storage.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist }
|
||||
from '../storage';
|
||||
|
||||
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store: Record<string, unknown> = { ...initial };
|
||||
(global as { chrome: unknown }).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((keys: string | string[]) => {
|
||||
const arr = Array.isArray(keys) ? keys : [keys];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of arr) if (k in store) out[k] = store[k];
|
||||
return Promise.resolve(out);
|
||||
}),
|
||||
set: vi.fn((kv: Record<string, unknown>) => {
|
||||
Object.assign(store, kv);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('service-worker/storage', () => {
|
||||
beforeEach(() => { mockChromeStorage(); });
|
||||
|
||||
it('loadDeviceSettings returns default when storage is empty', async () => {
|
||||
const s = await loadDeviceSettings();
|
||||
expect(s.captureEnabled).toBe(false);
|
||||
expect(s.captureStyle).toBe('bar');
|
||||
});
|
||||
|
||||
it('loadDeviceSettings returns stored value', async () => {
|
||||
mockChromeStorage({ relicarioSettings: { captureEnabled: true, captureStyle: 'toast' } });
|
||||
const s = await loadDeviceSettings();
|
||||
expect(s.captureEnabled).toBe(true);
|
||||
expect(s.captureStyle).toBe('toast');
|
||||
});
|
||||
|
||||
it('saveDeviceSettings persists', async () => {
|
||||
const store = mockChromeStorage();
|
||||
await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' });
|
||||
expect(store.relicarioSettings).toEqual({ captureEnabled: true, captureStyle: 'bar' });
|
||||
});
|
||||
|
||||
it('loadBlacklist returns empty array by default', async () => {
|
||||
expect(await loadBlacklist()).toEqual([]);
|
||||
});
|
||||
|
||||
it('saveBlacklist / loadBlacklist round-trips', async () => {
|
||||
await saveBlacklist(['example.com', 'evil.test']);
|
||||
expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']);
|
||||
});
|
||||
});
|
||||
53
extension/src/service-worker/__tests__/vault-status.test.ts
Normal file
53
extension/src/service-worker/__tests__/vault-status.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { handleGetVaultStatus } from '../vault';
|
||||
import type { Manifest, ManifestEntry } from '../../shared/types';
|
||||
|
||||
// The handler only reads gitHost's three cache fields, so the test feeds a
|
||||
// minimal object — the handler's Pick-typed param makes full GitHost mocking
|
||||
// unnecessary.
|
||||
const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) =>
|
||||
({ lastSyncAt, ahead, behind });
|
||||
|
||||
function manifestWith(activeCount: number, trashedCount = 0): Manifest {
|
||||
const items: Record<string, ManifestEntry> = {};
|
||||
for (let i = 0; i < activeCount; i++) {
|
||||
items[`a${i}`] = { trashed_at: undefined } as ManifestEntry;
|
||||
}
|
||||
for (let i = 0; i < trashedCount; i++) {
|
||||
items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry;
|
||||
}
|
||||
return { items } as Manifest;
|
||||
}
|
||||
|
||||
describe('handleGetVaultStatus', () => {
|
||||
it('returns zeros when never synced and no manifest', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: null });
|
||||
expect(resp).toEqual({
|
||||
ok: true,
|
||||
data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reflects cached sync state + active (non-trashed) item count', () => {
|
||||
const resp = handleGetVaultStatus({
|
||||
gitHost: cache(1234567890, 3, 1),
|
||||
manifest: manifestWith(5, 2),
|
||||
});
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.data).toEqual({
|
||||
ahead: 3, behind: 1, lastSyncAt: 1234567890, pendingItems: 5,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns vault_locked error when gitHost is null', () => {
|
||||
expect(handleGetVaultStatus({ gitHost: null, manifest: null }))
|
||||
.toEqual({ ok: false, error: 'vault_locked' });
|
||||
});
|
||||
|
||||
it('is synchronous — no network round-trip', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(0), manifest: null });
|
||||
expect(resp).not.toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
304
extension/src/service-worker/__tests__/vault.test.ts
Normal file
304
extension/src/service-worker/__tests__/vault.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vault from '../vault';
|
||||
import * as session from '../session';
|
||||
import type { PopupState } from '../router/popup-only';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as gitHostMod from '../git-host';
|
||||
|
||||
// --- Mock git-host module ---
|
||||
// createGitHost is called internally by handleCreateVault / handleAttachVault;
|
||||
// we need to intercept it and return a fake GitHost. uint8ArrayToBase64 must
|
||||
// still work — vault.ts calls it for the imageBase64 storage value.
|
||||
|
||||
// Shared factory used both inside vi.mock and in beforeEach re-wire.
|
||||
function makeHostMock(): GitHost & { _calls: Record<string, unknown[][]> } {
|
||||
const calls: Record<string, unknown[][]> = {
|
||||
writeFileCreateOnly: [],
|
||||
writeFile: [],
|
||||
readFile: [],
|
||||
};
|
||||
return {
|
||||
_calls: calls,
|
||||
readFile: vi.fn().mockImplementation(async (path: string) => {
|
||||
// Serve the vault-meta files needed by fetchVaultMeta + attach flow.
|
||||
if (path === '.relicario/salt') return new Uint8Array(32);
|
||||
if (path === '.relicario/params.json') {
|
||||
return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}');
|
||||
}
|
||||
if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]);
|
||||
// .relicario/devices.json throws so readDevices falls back to [].
|
||||
throw new Error(`404: ${path}`);
|
||||
}),
|
||||
writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||
calls.writeFile.push(args);
|
||||
}),
|
||||
writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||
calls.writeFileCreateOnly.push(args);
|
||||
}),
|
||||
deleteFile: vi.fn(),
|
||||
listDir: vi.fn().mockResolvedValue([]),
|
||||
lastCommit: vi.fn().mockResolvedValue(null),
|
||||
putBlob: vi.fn(),
|
||||
getBlob: vi.fn(),
|
||||
deleteBlob: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../git-host', async () => {
|
||||
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
|
||||
|
||||
// Expose a handle so tests can grab the last-created fake host.
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = null;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createGitHost: vi.fn().mockImplementation(() => {
|
||||
const h = makeHostMock();
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||
return h;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Chrome storage mock ---
|
||||
|
||||
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store: Record<string, unknown> = { ...initial };
|
||||
(global as { chrome: unknown }).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((keys: string | string[]) => {
|
||||
const arr = Array.isArray(keys) ? keys : [keys];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of arr) if (k in store) out[k] = store[k];
|
||||
return Promise.resolve(out);
|
||||
}),
|
||||
set: vi.fn((kv: Record<string, unknown>) => {
|
||||
Object.assign(store, kv);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return store;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeFakeHandle() {
|
||||
return { free: vi.fn() };
|
||||
}
|
||||
|
||||
function makeWasm(overrides: Record<string, unknown> = {}) {
|
||||
const fakeHandle = makeFakeHandle();
|
||||
return {
|
||||
_handle: fakeHandle,
|
||||
embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])),
|
||||
unlock: vi.fn(() => fakeHandle),
|
||||
manifest_encrypt: vi.fn(() => new Uint8Array([9])),
|
||||
manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })),
|
||||
default_vault_settings_json: vi.fn(() => '{}'),
|
||||
settings_encrypt: vi.fn(() => new Uint8Array([8])),
|
||||
register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })),
|
||||
lock: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(wasm: ReturnType<typeof makeWasm>): PopupState {
|
||||
return {
|
||||
manifest: null,
|
||||
gitHost: null,
|
||||
wasm,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_MSG = {
|
||||
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
|
||||
passphrase: 'pw',
|
||||
carrierImageBytes: new Uint8Array([0, 0, 0]).buffer,
|
||||
deviceName: 'Dev',
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('handleCreateVault', () => {
|
||||
let setCurrent: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChromeStorage();
|
||||
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Test 1 (happy path): returns ok:true with expected data and correct side effects', async () => {
|
||||
const wasm = makeWasm();
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleCreateVault(BASE_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) throw new Error('expected ok:true');
|
||||
|
||||
// Response shape
|
||||
expect(resp.data.referenceImageBytes).toBeInstanceOf(Uint8Array);
|
||||
expect(resp.data.deviceName).toBe('Dev');
|
||||
expect(resp.data.recoveryQrAvailable).toBe(true);
|
||||
|
||||
// Fake GitHost captures the four writeFileCreateOnly calls
|
||||
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
|
||||
expect(fakeHost).not.toBeNull();
|
||||
const wfco = fakeHost!.writeFileCreateOnly as ReturnType<typeof vi.fn>;
|
||||
const paths = wfco.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(paths).toContain('.relicario/salt');
|
||||
expect(paths).toContain('.relicario/params.json');
|
||||
expect(paths).toContain('manifest.enc');
|
||||
expect(paths).toContain('settings.enc');
|
||||
|
||||
// register_device called with the device name
|
||||
expect(wasm.register_device).toHaveBeenCalledWith('Dev');
|
||||
|
||||
// chrome.storage.local.set called with vaultConfig + imageBase64 + device_name
|
||||
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
|
||||
.chrome.storage.local.set.mock.calls;
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [kv] of chromeSets) Object.assign(merged, kv);
|
||||
expect(merged).toHaveProperty('vaultConfig');
|
||||
expect(merged).toHaveProperty('imageBase64');
|
||||
expect(merged).toHaveProperty('device_name', 'Dev');
|
||||
|
||||
// session.setCurrent was called (ownership transferred — handle NOT freed)
|
||||
expect(setCurrent).toHaveBeenCalled();
|
||||
expect(wasm._handle.free).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 2 (failure path — early throw): ok:false, no writeFileCreateOnly calls', async () => {
|
||||
const wasm = makeWasm({
|
||||
embed_image_secret: vi.fn(() => { throw new Error('embed failed'); }),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleCreateVault(BASE_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (resp.ok) throw new Error('expected ok:false');
|
||||
expect(resp.error).toBeTruthy();
|
||||
expect(resp.error.length).toBeGreaterThan(0);
|
||||
|
||||
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
|
||||
// No GitHost was created at all (failed before createGitHost call), OR
|
||||
// if somehow created, no writeFileCreateOnly calls happened.
|
||||
if (fakeHost) {
|
||||
expect((fakeHost.writeFileCreateOnly as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 3 (handle cleanup on mid-flight failure): lock + free called, ok:false', async () => {
|
||||
const wasm = makeWasm({
|
||||
manifest_encrypt: vi.fn(() => { throw new Error('encrypt failed'); }),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleCreateVault(BASE_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
|
||||
// unlock succeeded (handle was acquired), manifest_encrypt failed after that.
|
||||
// Finally block must: lock(handle) then handle.free().
|
||||
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
|
||||
expect(wasm._handle.free).toHaveBeenCalled();
|
||||
|
||||
// Ownership was NOT transferred — setCurrent must NOT have been called.
|
||||
expect(setCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- attach_vault ---
|
||||
|
||||
const ATTACH_MSG = {
|
||||
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
|
||||
passphrase: 'pw',
|
||||
referenceImageBytes: new Uint8Array([1, 2, 3]).buffer,
|
||||
deviceName: 'Dev2',
|
||||
};
|
||||
|
||||
describe('handleAttachVault', () => {
|
||||
let setCurrent: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChromeStorage();
|
||||
// Re-wire createGitHost: vi.restoreAllMocks() in the create-vault afterEach
|
||||
// strips the mockImplementation from the vi.fn(), leaving it returning undefined.
|
||||
// We re-establish it here so each attach test starts with a fresh fake host.
|
||||
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
|
||||
const h = makeHostMock();
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||
return h;
|
||||
});
|
||||
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Test 1 (happy path): returns ok:true, state populated, handle ownership transferred', async () => {
|
||||
const wasm = makeWasm();
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) throw new Error('expected ok:true');
|
||||
expect(resp.data.deviceName).toBe('Dev2');
|
||||
|
||||
// WASM calls in order: unlock → manifest_decrypt (verification) → register_device
|
||||
expect(wasm.unlock).toHaveBeenCalled();
|
||||
expect(wasm.manifest_decrypt).toHaveBeenCalled();
|
||||
expect(wasm.register_device).toHaveBeenCalledWith('Dev2');
|
||||
|
||||
// chrome.storage.local.set received vaultConfig + imageBase64 + device_name
|
||||
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
|
||||
.chrome.storage.local.set.mock.calls;
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [kv] of chromeSets) Object.assign(merged, kv);
|
||||
expect(merged).toHaveProperty('vaultConfig');
|
||||
expect(merged).toHaveProperty('imageBase64');
|
||||
expect(merged).toHaveProperty('device_name', 'Dev2');
|
||||
|
||||
// session.setCurrent called — ownership transferred; handle NOT freed
|
||||
expect(setCurrent).toHaveBeenCalled();
|
||||
expect(wasm._handle.free).not.toHaveBeenCalled();
|
||||
|
||||
// State wired up
|
||||
expect(state.manifest).not.toBeNull();
|
||||
expect(state.gitHost).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Test 2 (wrong credentials — manifest_decrypt throws): ok:false, handle locked+freed, no side effects', async () => {
|
||||
const wasm = makeWasm({
|
||||
manifest_decrypt: vi.fn(() => { throw new Error('AEAD verification failed'); }),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (resp.ok) throw new Error('expected ok:false');
|
||||
expect(resp.error).toBeTruthy();
|
||||
expect(resp.error.length).toBeGreaterThan(0);
|
||||
|
||||
// register_device must NOT be called (we failed before it)
|
||||
expect(wasm.register_device).not.toHaveBeenCalled();
|
||||
|
||||
// Finally block must lock then free the handle we own
|
||||
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
|
||||
expect(wasm._handle.free).toHaveBeenCalled();
|
||||
|
||||
// Session must NOT have been updated
|
||||
expect(setCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,15 @@ export interface GitHost {
|
||||
/// Delete a blob from the repo. Currently identical to deleteFile;
|
||||
/// kept distinct for symmetry with putBlob.
|
||||
deleteBlob(path: string, message: string): Promise<void>;
|
||||
|
||||
/// Cached sync metadata, populated by the `sync` handler — get_vault_status
|
||||
/// reads these without any network call. lastSyncAt is unix SECONDS (or null
|
||||
/// until the first sync). ahead/behind exist for parity with `relicario
|
||||
/// status`; the extension writes straight to the host (no local commit
|
||||
/// graph), so in practice they stay 0.
|
||||
lastSyncAt: number | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
/// Pre-base64 byte size at which putBlob switches from Contents API to
|
||||
|
||||
@@ -20,6 +20,9 @@ export class GiteaHost implements GitHost {
|
||||
private keysUrl: string;
|
||||
private branch: string = 'main';
|
||||
private headers: Record<string, string>;
|
||||
lastSyncAt: number | null = null;
|
||||
ahead = 0;
|
||||
behind = 0;
|
||||
|
||||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||||
// Remove trailing slash from hostUrl
|
||||
|
||||
@@ -17,6 +17,9 @@ export class GitHubHost implements GitHost {
|
||||
private commitsUrl: string;
|
||||
private branch: string = 'main';
|
||||
private headers: Record<string, string>;
|
||||
lastSyncAt: number | null = null;
|
||||
ahead = 0;
|
||||
behind = 0;
|
||||
|
||||
constructor(repoPath: string, apiToken: string) {
|
||||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
/// forwards every message into router/index.route().
|
||||
|
||||
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
|
||||
import { CONTENT_CALLABLE_TYPES } from '../shared/messages';
|
||||
import type { RouterState } from './router/index';
|
||||
import { route } from './router/index';
|
||||
import * as vault from './vault';
|
||||
import { clearCurrent } from './session';
|
||||
import * as sessionTimer from './session-timer';
|
||||
import { READ_ONLY_CONTENT_CALLABLE } from './session-timer';
|
||||
|
||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||
@@ -53,6 +53,9 @@ sessionTimer.onExpired(() => {
|
||||
console.log('[relicario sw] session expired — locking vault');
|
||||
clearCurrent();
|
||||
state.manifest = null;
|
||||
// Plan C Phase 5: don't leak the cached git-host client across a lock.
|
||||
// The initializer rebuilds gitHost on demand, so clearing here is safe.
|
||||
state.gitHost = null;
|
||||
// Best-effort broadcast — receiver may not exist yet.
|
||||
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
|
||||
});
|
||||
@@ -73,7 +76,10 @@ chrome.commands.onCommand.addListener((command) => {
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||
(async () => {
|
||||
if (!CONTENT_CALLABLE_TYPES.has(request.type as never)) {
|
||||
// Plan C Phase 5: invert the reset rule. Reset on every message
|
||||
// except a documented passive-read exclusion set, so an active
|
||||
// autofiller / content-driven flow keeps the vault alive.
|
||||
if (!READ_ONLY_CONTENT_CALLABLE.has(request.type)) {
|
||||
sessionTimer.resetTimer();
|
||||
}
|
||||
if (!state.wasm) {
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { ContentMessage, Response } from '../../shared/messages';
|
||||
import type { Item, Manifest } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import { itemToManifestEntry } from '../vault';
|
||||
import * as session from '../session';
|
||||
import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
||||
|
||||
export interface ContentState {
|
||||
manifest: Manifest | null;
|
||||
@@ -164,41 +166,6 @@ export async function handle(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from popup-only for self-containment) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
|
||||
?? { captureEnabled: false, captureStyle: 'bar' };
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { base32Decode } from '../../shared/base32';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import { itemToManifestEntry } from '../vault';
|
||||
import * as session from '../session';
|
||||
import * as devices from '../devices';
|
||||
import * as sessionTimer from '../session-timer';
|
||||
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
||||
|
||||
// --- Shared ambient state owned by the SW module ---
|
||||
//
|
||||
@@ -58,6 +59,9 @@ export async function handle(
|
||||
case 'lock':
|
||||
session.clearCurrent();
|
||||
state.manifest = null;
|
||||
// Don't leak the cached git-host (incl. lastSyncAt) across a lock —
|
||||
// symmetric with the session-expiry path (index.ts); unlock rebuilds it.
|
||||
state.gitHost = null;
|
||||
return { ok: true };
|
||||
|
||||
case 'list_items': {
|
||||
@@ -129,6 +133,8 @@ export async function handle(
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||||
// Record sync time (unix SECONDS) for the get_vault_status indicator.
|
||||
state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -626,6 +632,18 @@ export async function handle(
|
||||
return { ok: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'create_vault':
|
||||
return vault.handleCreateVault(msg, state);
|
||||
|
||||
case 'attach_vault':
|
||||
return vault.handleAttachVault(msg, state);
|
||||
|
||||
case 'get_vault_status':
|
||||
return vault.handleGetVaultStatus(state);
|
||||
|
||||
default:
|
||||
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,44 +702,6 @@ async function loadSetupState(): Promise<SetupState> {
|
||||
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||
}
|
||||
|
||||
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: s });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -48,3 +48,17 @@ export function stopTimer(): void {
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-callable message types that should NOT reset the inactivity timer.
|
||||
*
|
||||
* Rationale: a content script reading available autofill candidates is a
|
||||
* passive query — it shouldn't keep the vault alive indefinitely while the
|
||||
* user isn't actually interacting with it.
|
||||
*
|
||||
* Today this is the only known passive read; if a future content message
|
||||
* is also passive, add it here with a one-line justification.
|
||||
*/
|
||||
export const READ_ONLY_CONTENT_CALLABLE: ReadonlySet<string> = new Set([
|
||||
'get_autofill_candidates',
|
||||
]);
|
||||
|
||||
25
extension/src/service-worker/storage.ts
Normal file
25
extension/src/service-worker/storage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/// Single home for chrome.storage.local reads/writes done by the service
|
||||
/// worker. Both router files (popup-only.ts and content-callable.ts) import
|
||||
/// from here — the duplicated definitions in those files were lifted out as
|
||||
/// part of Plan C Phase 2 (P1.9).
|
||||
|
||||
import type { DeviceSettings } from '../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
|
||||
|
||||
export async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||
}
|
||||
|
||||
export async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: s });
|
||||
}
|
||||
|
||||
export async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
export async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||
import type { GitHost } from './git-host';
|
||||
import { uint8ArrayToBase64 } from './git-host';
|
||||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
|
||||
import { createGitHost, uint8ArrayToBase64 } from './git-host';
|
||||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
|
||||
import * as session from './session';
|
||||
import * as devices from './devices';
|
||||
import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages';
|
||||
import type { PopupState } from './router/popup-only';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let wasm: any = null;
|
||||
@@ -17,6 +21,125 @@ function requireWasm(): any {
|
||||
return wasm;
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
||||
|
||||
/// Register this device on the remote (devices.json) and persist the vault
|
||||
/// config + reference image locally so future unlocks work. Shared by the
|
||||
/// create and attach flows — both finish with this identical tail.
|
||||
async function registerDeviceAndPersistConfig(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
w: any,
|
||||
git: GitHost,
|
||||
config: VaultConfig,
|
||||
referenceImageBytes: Uint8Array,
|
||||
deviceName: string,
|
||||
): Promise<void> {
|
||||
const keys = w.register_device(deviceName) as { signing_public_key: string };
|
||||
await devices.addDevice(git, {
|
||||
name: deviceName,
|
||||
public_key: keys.signing_public_key,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
await chrome.storage.local.set({
|
||||
vaultConfig: config,
|
||||
imageBase64: uint8ArrayToBase64(referenceImageBytes),
|
||||
device_name: deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCreateVault(
|
||||
msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string },
|
||||
state: PopupState,
|
||||
): Promise<CreateVaultResponse | { ok: false; error: string }> {
|
||||
const w = state.wasm;
|
||||
let handle: SessionHandle | null = null;
|
||||
try {
|
||||
const carrierBytes = new Uint8Array(msg.carrierImageBytes);
|
||||
const imageSecret = new Uint8Array(32);
|
||||
crypto.getRandomValues(imageSecret);
|
||||
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));
|
||||
|
||||
const salt = new Uint8Array(32);
|
||||
crypto.getRandomValues(salt);
|
||||
// Capture the unlock result in a non-null binding for the in-scope ops;
|
||||
// `handle` stays the ownership tracker the finally block cleans up.
|
||||
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON);
|
||||
handle = h;
|
||||
|
||||
const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
|
||||
const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));
|
||||
|
||||
const { config } = msg;
|
||||
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
|
||||
await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters');
|
||||
await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
|
||||
await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');
|
||||
|
||||
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
|
||||
|
||||
// SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
|
||||
session.setCurrent(h);
|
||||
state.gitHost = git;
|
||||
state.manifest = { schema_version: 2, items: {} } as Manifest;
|
||||
handle = null; // ownership transferred — do NOT lock-and-free in finally
|
||||
|
||||
return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
// Plan A .free() policy (docs/...extension-restructure-design.md Risks): lock THEN free,
|
||||
// and only if we still own the handle (success path transfers ownership to session.setCurrent).
|
||||
if (handle) {
|
||||
try { w.lock(handle); } catch { /* lock may already have happened */ }
|
||||
handle.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAttachVault(
|
||||
msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
|
||||
state: PopupState,
|
||||
): Promise<AttachVaultResponse | { ok: false; error: string }> {
|
||||
const w = state.wasm;
|
||||
let handle: SessionHandle | null = null;
|
||||
try {
|
||||
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
|
||||
const { config } = msg;
|
||||
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
|
||||
// The vault metadata and manifest are independent read-only GETs — fan out.
|
||||
const [meta, encryptedManifest] = await Promise.all([
|
||||
fetchVaultMeta(git),
|
||||
git.readFile('manifest.enc'),
|
||||
]);
|
||||
|
||||
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
|
||||
handle = h;
|
||||
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
|
||||
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
|
||||
|
||||
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
|
||||
|
||||
// SW now owns the unlocked session — transfer ownership to the session.
|
||||
session.setCurrent(h);
|
||||
state.gitHost = git;
|
||||
state.manifest = manifest;
|
||||
handle = null; // ownership transferred — do NOT lock-and-free in finally
|
||||
|
||||
return { ok: true, data: { deviceName: msg.deviceName } };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
// Same .free() policy as handleCreateVault: lock THEN free, only if we still
|
||||
// own the handle (success path transfers ownership to session.setCurrent).
|
||||
if (handle) {
|
||||
try { w.lock(handle); } catch { /* lock may already have happened */ }
|
||||
handle.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface VaultMeta {
|
||||
salt: Uint8Array;
|
||||
paramsJson: string;
|
||||
@@ -395,3 +518,62 @@ export async function removeAttachmentsFromItem(
|
||||
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
|
||||
return removed;
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation ---
|
||||
|
||||
/**
|
||||
* Project a decrypted Item into its ManifestEntry shape for browse-without-
|
||||
* decrypt views. Both router files use this; defined here (the SW's
|
||||
* vault-orchestration home) instead of duplicated in each router. Moved out
|
||||
* of popup-only.ts / content-callable.ts as part of Plan C Phase 2 (P1.9).
|
||||
*/
|
||||
export function itemToManifestEntry(item: Item): ManifestEntry {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
|
||||
// --- Vault status (Plan C Phase 6) ---
|
||||
|
||||
/**
|
||||
* Return the cached vault status for the sidebar indicator. Reads cached sync
|
||||
* metadata off the GitHost (populated by the `sync` handler) plus a live count
|
||||
* of active (non-trashed) items from the in-memory manifest. Does NOT touch
|
||||
* the network — sync is user-initiated (spec 2026-05-04, Phase 6). The
|
||||
* Pick-typed gitHost param both avoids a circular import of the router's
|
||||
* PopupState and structurally forbids a network call from here.
|
||||
*/
|
||||
export function handleGetVaultStatus(
|
||||
state: {
|
||||
gitHost: Pick<GitHost, 'lastSyncAt' | 'ahead' | 'behind'> | null;
|
||||
manifest: Manifest | null;
|
||||
},
|
||||
): GetVaultStatusResponse | { ok: false; error: string } {
|
||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const pendingItems = state.manifest ? listItems(state.manifest).length : 0;
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
ahead: state.gitHost.ahead,
|
||||
behind: state.gitHost.behind,
|
||||
lastSyncAt: state.gitHost.lastSyncAt,
|
||||
pendingItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { finishSetup } from '../setup';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { finishSetup, STEPS } from '../setup';
|
||||
import { state, clearWizardState } from '../setup-steps';
|
||||
|
||||
describe('finishSetup', () => {
|
||||
beforeEach(() => {
|
||||
@@ -35,3 +36,47 @@ describe('finishSetup', () => {
|
||||
expect(chrome.tabs.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup step registry', () => {
|
||||
it('has the six steps in canonical order', () => {
|
||||
expect(STEPS.map((s) => s.id)).toEqual(['mode', 'host', 'connection', 'vault', 'device', 'done']);
|
||||
});
|
||||
|
||||
it('each step renders non-empty HTML and attach returns a teardown', () => {
|
||||
const ctx = { state: {} as never, rerender: vi.fn(), goto: vi.fn() };
|
||||
for (const step of STEPS) {
|
||||
const html = step.render(ctx as never);
|
||||
expect(typeof html).toBe('string');
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
// render output must be in the DOM before attach (attach wires getElementById listeners)
|
||||
document.body.innerHTML = `<div id="app">${html}</div>`;
|
||||
const teardown = step.attach(document.body, ctx as never);
|
||||
expect(typeof teardown).toBe('function');
|
||||
teardown(); // must not throw
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearWizardState', () => {
|
||||
afterEach(() => {
|
||||
clearWizardState();
|
||||
});
|
||||
|
||||
it('zero-fills the reachable Uint8Array fields and resets state', () => {
|
||||
const carrier = new Uint8Array([1, 2, 3, 4]);
|
||||
const ref = new Uint8Array([5, 6, 7, 8]);
|
||||
state.carrierImageBytes = carrier;
|
||||
state.referenceImageBytes = ref;
|
||||
state.passphrase = 'secret';
|
||||
state.mode = 'new';
|
||||
|
||||
clearWizardState();
|
||||
|
||||
expect(Array.from(carrier)).toEqual([0, 0, 0, 0]); // fill(0) ran on the captured ref
|
||||
expect(Array.from(ref)).toEqual([0, 0, 0, 0]);
|
||||
expect(state.carrierImageBytes).toBeNull(); // field reset
|
||||
expect(state.referenceImageBytes).toBeNull();
|
||||
expect(state.passphrase).toBe('');
|
||||
expect(state.mode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
805
extension/src/setup/setup-steps.ts
Normal file
805
extension/src/setup/setup-steps.ts
Normal file
@@ -0,0 +1,805 @@
|
||||
import { createGitHost } from '../service-worker/git-host';
|
||||
import { probeVault } from './probe';
|
||||
import type { VaultProbe } from './probe';
|
||||
import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers';
|
||||
import { GLYPH_NEXT } from '../shared/glyphs';
|
||||
import type { VaultConfig } from '../shared/types';
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
|
||||
// --- SW messaging ---
|
||||
|
||||
export function swSend(msg: Request): Promise<Response> {
|
||||
return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r)));
|
||||
}
|
||||
|
||||
// --- Step registry types ---
|
||||
|
||||
export type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done';
|
||||
|
||||
export interface StepContext {
|
||||
state: WizardState;
|
||||
rerender: () => void;
|
||||
goto: (id: StepId) => void;
|
||||
}
|
||||
|
||||
export interface SetupStep {
|
||||
id: StepId;
|
||||
render: (ctx: StepContext) => string;
|
||||
attach: (root: HTMLElement, ctx: StepContext) => () => void;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
export interface WizardState {
|
||||
stepId: StepId;
|
||||
mode: 'new' | 'attach' | null;
|
||||
hostType: 'gitea' | 'github';
|
||||
hostUrl: string;
|
||||
repoPath: string;
|
||||
apiToken: string;
|
||||
connectionTested: boolean;
|
||||
vaultProbe: VaultProbe | null;
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
referenceImageBytesAttach: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
passphraseScore: number;
|
||||
passphraseGuessesLog10: number;
|
||||
passphraseVisible: boolean;
|
||||
confirmVisible: boolean;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
attaching: boolean;
|
||||
error: string | null;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
export const state: WizardState = {
|
||||
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
|
||||
connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null,
|
||||
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
|
||||
creating: false, attaching: false, error: null, deviceName: '',
|
||||
};
|
||||
|
||||
// --- State-coupled helpers ---
|
||||
|
||||
function updateStrengthUi(): void {
|
||||
const bar = document.getElementById('strength-bar');
|
||||
const label = document.getElementById('strength-label');
|
||||
const entropy = document.getElementById('entropy-line');
|
||||
const counter = document.getElementById('passphrase-counter');
|
||||
const matchInd = document.getElementById('match-indicator');
|
||||
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
||||
const score = state.passphraseScore;
|
||||
|
||||
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
||||
if (label) {
|
||||
if (score < 0) { label.className = 'strength-label'; label.innerHTML = ' '; }
|
||||
else {
|
||||
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
||||
label.className = `strength-label ${meta.cls}`;
|
||||
label.textContent = meta.text;
|
||||
}
|
||||
}
|
||||
if (entropy) {
|
||||
const txt = entropyText(state.passphraseGuessesLog10);
|
||||
entropy.textContent = txt;
|
||||
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
||||
}
|
||||
if (counter) {
|
||||
const n = state.passphrase.length;
|
||||
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (matchInd) {
|
||||
const p = state.passphrase, c = state.passphraseConfirm;
|
||||
if (!p || !c) { matchInd.className = 'match-indicator'; matchInd.textContent = ''; }
|
||||
else if (p === c) { matchInd.className = 'match-indicator ok'; matchInd.textContent = '✓'; }
|
||||
else { matchInd.className = 'match-indicator bad'; matchInd.textContent = '✗'; }
|
||||
}
|
||||
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
||||
if (create) {
|
||||
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
||||
create.disabled = disabled;
|
||||
create.title = disabled
|
||||
? (score < 3 ? 'passphrase must score "good" or better'
|
||||
: !state.passphraseConfirm ? 'confirm your passphrase'
|
||||
: !matchOk ? 'passphrases do not match' : '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function vaultConfig(): VaultConfig {
|
||||
return {
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
};
|
||||
}
|
||||
|
||||
// --- mode ---
|
||||
|
||||
const modeStep: SetupStep = {
|
||||
id: 'mode',
|
||||
render() {
|
||||
const isNew = state.mode === 'new';
|
||||
const isAttach = state.mode === 'attach';
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>set up Relicario</h3>
|
||||
<p class="muted" style="margin-bottom:16px;">How are you using Relicario on this device?</p>
|
||||
<div class="mode-cards">
|
||||
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
|
||||
<span class="mode-card__icon" style="font-size:28px;">◈</span>
|
||||
<div class="mode-card-title">create new vault</div>
|
||||
<p class="mode-card-blurb">I'm setting up Relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository.</p>
|
||||
</button>
|
||||
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
|
||||
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
|
||||
<div class="mode-card-title">attach this device</div>
|
||||
<p class="mode-card-blurb">I already have a vault on another device. Connect this browser to it using my passphrase and reference image.</p>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:24px;">
|
||||
<button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.querySelectorAll('.mode-card').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach';
|
||||
ctx.rerender();
|
||||
});
|
||||
});
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||
if (state.mode) ctx.goto('host');
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- host ---
|
||||
|
||||
const GITEA_INSTRUCTIONS = `
|
||||
<div class="step-instructions"><ol>
|
||||
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
|
||||
<li>Go to <strong>Settings → Applications</strong></li>
|
||||
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
|
||||
<li>Copy the token — you will need it in the next step</li>
|
||||
</ol></div>`;
|
||||
|
||||
const GITHUB_INSTRUCTIONS = `
|
||||
<div class="step-instructions"><ol>
|
||||
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
|
||||
<li>Go to <strong>Settings → Developer settings → Personal access tokens → Fine-grained tokens</strong></li>
|
||||
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
|
||||
<li>Copy the token — you will need it in the next step</li>
|
||||
</ol></div>`;
|
||||
|
||||
const hostStep: SetupStep = {
|
||||
id: 'host',
|
||||
render() {
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>choose host</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">host type</label>
|
||||
<div class="toggle-group">
|
||||
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
|
||||
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS}
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('mode'));
|
||||
document.querySelectorAll('.toggle-group button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
||||
state.connectionTested = false;
|
||||
ctx.rerender();
|
||||
});
|
||||
});
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- connection ---
|
||||
|
||||
function renderProbeBanner(): string {
|
||||
const probe = state.vaultProbe;
|
||||
if (!state.connectionTested || !probe) return '';
|
||||
const meta = probe.lastCommit
|
||||
? `Last commit: <code>${escapeHtml(probe.lastCommit.sha)}</code> by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.`
|
||||
: '';
|
||||
if (state.mode === 'new' && probe.exists) {
|
||||
return `
|
||||
<div class="banner banner-warn">
|
||||
<strong>⚠ This repository already contains a Relicario vault.</strong>
|
||||
<p>${meta}</p>
|
||||
<p>Creating a new vault here would overwrite the existing one and <strong>destroy all data inside</strong>. To use this vault on this device, switch to <em>attach</em> mode instead. If you really mean to start over, delete the repository via your git host's web UI and come back here.</p>
|
||||
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="attach">switch to attach mode</button></div>
|
||||
</div>`;
|
||||
}
|
||||
if (state.mode === 'attach' && !probe.exists) {
|
||||
return `
|
||||
<div class="banner banner-warn">
|
||||
<strong>No vault found in this repo.</strong>
|
||||
<p>Did you mean to create a new vault?</p>
|
||||
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="new">switch to new-vault mode</button></div>
|
||||
</div>`;
|
||||
}
|
||||
if (state.mode === 'attach' && probe.exists) {
|
||||
return `
|
||||
<div class="banner banner-ok">
|
||||
<strong>✓ Existing vault found.</strong>
|
||||
<p>${meta}</p>
|
||||
<p>Continue to attach this device.</p>
|
||||
</div>`;
|
||||
}
|
||||
// mode = new, !exists
|
||||
return `<div class="banner banner-ok"><strong>✓ Repo is empty — ready to create a new vault.</strong></div>`;
|
||||
}
|
||||
|
||||
const connectionStep: SetupStep = {
|
||||
id: 'connection',
|
||||
render() {
|
||||
const probe = state.vaultProbe;
|
||||
const modeMismatch =
|
||||
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
|
||||
const nextDisabled = !state.connectionTested || !probe || modeMismatch;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>configure connection</h3>
|
||||
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||
<label class="label" for="host-url">host url</label>
|
||||
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="repo-path">repository path</label>
|
||||
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="api-token">api token</label>
|
||||
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="test-btn">test connection</button>
|
||||
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
|
||||
</div>
|
||||
${renderProbeBanner()}
|
||||
<div class="form-actions" style="margin-top:12px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.getElementById('test-btn')?.addEventListener('click', async () => {
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
const hostUrl = state.hostType === 'github'
|
||||
? 'https://api.github.com'
|
||||
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
|
||||
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
|
||||
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
|
||||
|
||||
if (!repoPath || !apiToken) {
|
||||
state.error = 'Repository path and API token are required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (state.hostType === 'gitea' && !hostUrl) {
|
||||
state.error = 'Host URL is required for Gitea';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
state.hostUrl = hostUrl;
|
||||
state.repoPath = repoPath;
|
||||
state.apiToken = apiToken;
|
||||
try {
|
||||
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
|
||||
await host.listDir('');
|
||||
state.connectionTested = true;
|
||||
state.error = null;
|
||||
try {
|
||||
state.vaultProbe = await probeVault(host);
|
||||
} catch (probeErr) {
|
||||
state.vaultProbe = null;
|
||||
state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
ctx.rerender();
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('host'));
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||
if (state.connectionTested) ctx.goto('vault');
|
||||
});
|
||||
document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => {
|
||||
state.mode = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach';
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- vault ---
|
||||
|
||||
function renderVaultAttach(): string {
|
||||
const p = state.passphrase;
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const hasImage = !!state.referenceImageBytesAttach;
|
||||
const gateDisabled = !p || !hasImage;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>attach this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.</p>
|
||||
<div class="form-group">
|
||||
<label class="label">reference image (JPEG)</label>
|
||||
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
|
||||
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
|
||||
${hasImage ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter your passphrase" autocomplete="current-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn">${pToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="attach-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderVaultNew(): string {
|
||||
const score = state.passphraseScore;
|
||||
const hasScore = score >= 0;
|
||||
const meterClass = hasScore ? `s${score}` : '';
|
||||
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
||||
const labelClass = labelMeta?.cls ?? '';
|
||||
const labelText = labelMeta?.text ?? ' ';
|
||||
const entropy = entropyText(state.passphraseGuessesLog10);
|
||||
const p = state.passphrase, c = state.passphraseConfirm;
|
||||
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
||||
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const cType = state.confirmVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
||||
const matchOk = !c || p === c;
|
||||
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
||||
const nChars = p.length;
|
||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>create vault</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
|
||||
${state.carrierImageBytes ? '<p class="secondary">image loaded</p>' : '<p class="secondary">click to select a JPEG photo</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
<div class="pass-help">A long phrase of unrelated words is stronger than a short complex password. Your vault needs <strong>good</strong> (score ≥ 3) to continue.</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
||||
</div>
|
||||
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
||||
<div class="seg i0"></div><div class="seg i1"></div><div class="seg i2"></div><div class="seg i3"></div><div class="seg i4"></div>
|
||||
</div>
|
||||
<div class="strength-row">
|
||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
||||
</div>
|
||||
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
||||
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const vaultStep: SetupStep = {
|
||||
id: 'vault',
|
||||
render(ctx) {
|
||||
return ctx.state.mode === 'attach' ? renderVaultAttach() : renderVaultNew();
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
return state.mode === 'attach' ? attachVaultAttach(ctx) : attachVaultNew(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
function attachVaultAttach(ctx: StepContext): () => void {
|
||||
const refDrop = document.getElementById('ref-drop')!;
|
||||
const refInput = document.getElementById('ref-input') as HTMLInputElement;
|
||||
refDrop.addEventListener('click', () => refInput.click());
|
||||
refInput.addEventListener('change', () => {
|
||||
const file = refInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach;
|
||||
});
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
document.getElementById('attach-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytesAttach) {
|
||||
state.error = 'Please select your reference JPEG image';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (!state.passphrase) {
|
||||
state.error = 'Passphrase is required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
ctx.goto('device');
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function attachVaultNew(ctx: StepContext): () => void {
|
||||
const fileDrop = document.getElementById('file-drop')!;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
fileDrop.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
// Track passphrase changes inline (no full re-render) so the input keeps focus.
|
||||
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
scheduleRate(state.passphrase, (s) => {
|
||||
state.passphraseScore = s.score;
|
||||
state.passphraseGuessesLog10 = s.guessesLog10;
|
||||
updateStrengthUi();
|
||||
});
|
||||
});
|
||||
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
||||
confirmInput?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
});
|
||||
// Eye toggles — flip the input type and label without a full re-render so
|
||||
// focus + cursor position survive the click.
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
|
||||
state.confirmVisible = !state.confirmVisible;
|
||||
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('confirm-eye-btn');
|
||||
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
||||
confirmInput?.focus();
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
document.getElementById('create-btn')?.addEventListener('click', async () => {
|
||||
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
|
||||
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
|
||||
if (!state.carrierImageBytes) {
|
||||
state.error = 'Please select a carrier JPEG image';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (!state.passphrase) {
|
||||
state.error = 'Passphrase is required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
// Re-rate synchronously in case the button was clicked before the debounced
|
||||
// rater fired. Defence in depth — the button is already disabled when score < 3.
|
||||
const strength = await ratePassphrase(state.passphrase);
|
||||
state.passphraseScore = strength.score;
|
||||
state.passphraseGuessesLog10 = strength.guessesLog10;
|
||||
if (state.passphraseScore < 3) {
|
||||
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (state.passphrase !== state.passphraseConfirm) {
|
||||
state.error = 'Passphrases do not match';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
ctx.goto('device');
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// --- device ---
|
||||
|
||||
const deviceStep: SetupStep = {
|
||||
id: 'device',
|
||||
render() {
|
||||
const busy = state.creating || state.attaching;
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent);
|
||||
const isFirefox = /firefox/i.test(navigator.userAgent);
|
||||
const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser';
|
||||
const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux';
|
||||
const defaultName = state.deviceName || `${browser} on ${os}`;
|
||||
const busyLabel = state.attaching ? 'attaching…' : 'creating…';
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>name this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">This helps you identify which devices have access to your vault.</p>
|
||||
<div class="form-group">
|
||||
<label class="label" for="device-name">device name</label>
|
||||
<input id="device-name" type="text" value="${escapeHtml(defaultName)}" placeholder="e.g. Chrome on Linux" ${busy ? 'disabled' : ''}>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn" ${busy ? 'disabled' : ''}>back</button>
|
||||
<button class="btn-primary" id="next-btn" ${busy ? 'disabled' : ''}>${busy ? `<span class="spinner"></span> ${busyLabel}` : `continue ${GLYPH_NEXT}`}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
if (!state.creating && !state.attaching) ctx.goto('vault');
|
||||
});
|
||||
document.getElementById('next-btn')?.addEventListener('click', async () => {
|
||||
if (state.creating || state.attaching) return;
|
||||
const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
|
||||
if (!name) {
|
||||
state.error = 'Device name is required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
state.deviceName = name;
|
||||
state.error = null;
|
||||
if (state.mode === 'attach') {
|
||||
state.attaching = true;
|
||||
ctx.rerender();
|
||||
const resp = await swSend({
|
||||
type: 'attach_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
});
|
||||
state.attaching = false;
|
||||
if (resp.ok) ctx.goto('done');
|
||||
else { state.error = resp.error; ctx.rerender(); }
|
||||
} else {
|
||||
state.creating = true;
|
||||
ctx.rerender();
|
||||
const resp = await swSend({
|
||||
type: 'create_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
});
|
||||
state.creating = false;
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { referenceImageBytes: Uint8Array };
|
||||
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
|
||||
ctx.goto('done');
|
||||
} else { state.error = resp.error; ctx.rerender(); }
|
||||
}
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- done ---
|
||||
|
||||
const doneStep: SetupStep = {
|
||||
id: 'done',
|
||||
render() {
|
||||
const isAttach = state.mode === 'attach';
|
||||
const qrBannerHtml = isAttach ? '' : `
|
||||
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
||||
<div class="recovery-qr-banner__header">
|
||||
<span style="font-size:20px;">◫</span>
|
||||
<strong>Generate a recovery QR before you go</strong>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:4px 0 8px;">If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.</p>
|
||||
<div class="recovery-qr-banner__actions">
|
||||
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
|
||||
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const refSection = isAttach ? '' : `
|
||||
<div class="form-group">
|
||||
<label class="label">reference image</label>
|
||||
<p class="muted" style="margin-bottom:8px;">Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.</p>
|
||||
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<div class="success-box">
|
||||
<h3>${isAttach ? 'device attached' : 'vault created'}</h3>
|
||||
<p class="secondary">${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}</p>
|
||||
</div>
|
||||
${qrBannerHtml}
|
||||
${refSection}
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label class="label">extension configuration</label>
|
||||
<p class="muted" style="margin-bottom:8px;">
|
||||
Copy this JSON to configure Relicario on another setup, or save it for later.
|
||||
</p>
|
||||
<div class="config-blob" id="config-blob">${escapeHtml(JSON.stringify(vaultConfig(), null, 2))}</div>
|
||||
<button class="btn" id="copy-config-btn">copy to clipboard</button>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:16px;">
|
||||
<button class="btn btn-primary" id="open-vault-btn">open vault</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, _ctx) {
|
||||
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
|
||||
try {
|
||||
const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase });
|
||||
if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error);
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
|
||||
});
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) {
|
||||
banner.innerHTML = `
|
||||
<div style="text-align:center;">${svg}</div>
|
||||
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">◉ Recovery QR generated — save or print this now.</p>
|
||||
<div style="margin-top:8px;"><button class="btn" id="setup-qr-done">Done</button></div>`;
|
||||
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
|
||||
banner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
|
||||
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
});
|
||||
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'reference.jpg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
|
||||
const blob = document.getElementById('config-blob');
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(blob.textContent ?? '');
|
||||
const btn = document.getElementById('copy-config-btn')!;
|
||||
btn.textContent = 'copied!';
|
||||
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
|
||||
} catch {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(blob);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
});
|
||||
document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup());
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
export const STEPS: ReadonlyArray<SetupStep> = [
|
||||
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
|
||||
];
|
||||
|
||||
// --- Sensitive-state cleanup ---
|
||||
|
||||
export function clearWizardState(): void {
|
||||
// Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays.
|
||||
state.carrierImageBytes?.fill(0);
|
||||
state.referenceImageBytes?.fill(0);
|
||||
state.referenceImageBytesAttach?.fill(0);
|
||||
state.mode = null;
|
||||
state.hostType = 'gitea';
|
||||
state.hostUrl = '';
|
||||
state.repoPath = '';
|
||||
state.apiToken = '';
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
state.carrierImageBytes = null;
|
||||
state.referenceImageBytesAttach = null;
|
||||
state.passphrase = '';
|
||||
state.passphraseConfirm = '';
|
||||
state.passphraseScore = -1;
|
||||
state.passphraseGuessesLog10 = -1;
|
||||
state.passphraseVisible = false;
|
||||
state.confirmVisible = false;
|
||||
state.referenceImageBytes = null;
|
||||
state.creating = false;
|
||||
state.attaching = false;
|
||||
state.error = null;
|
||||
state.deviceName = '';
|
||||
}
|
||||
|
||||
// --- Completion handoff ---
|
||||
|
||||
/// Open the fullscreen vault tab and best-effort close the setup tab.
|
||||
export async function finishSetup(): Promise<void> {
|
||||
const vaultUrl = chrome.runtime.getURL('vault.html');
|
||||
await chrome.tabs.create({ url: vaultUrl });
|
||||
try {
|
||||
const current = await chrome.tabs.getCurrent();
|
||||
if (current?.id !== undefined) {
|
||||
await chrome.tabs.remove(current.id);
|
||||
}
|
||||
} catch {
|
||||
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
|
||||
// The vault tab is open — that's the user-visible success.
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerHost, __resetHostForTests, sendMessage } from '../state';
|
||||
import type { StateHost } from '../state';
|
||||
import type { Response } from '../messages';
|
||||
|
||||
function makeHost(response: { ok: boolean; error?: string }): StateHost {
|
||||
return {
|
||||
getState: () => ({ view: 'list' } as never),
|
||||
setState: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue(response as Response),
|
||||
escapeHtml: (s) => s,
|
||||
popOutToTab: vi.fn(),
|
||||
isInTab: () => false,
|
||||
openVaultTab: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('shared/state sendMessage vault_locked intercept', () => {
|
||||
beforeEach(() => __resetHostForTests());
|
||||
|
||||
it('navigates to the lock screen on a vault_locked response', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).toHaveBeenCalledWith(
|
||||
'locked',
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT intercept the unlock request itself', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'unlock', passphrase: 'x' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT intercept is_unlocked', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'is_unlocked' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept a successful response', async () => {
|
||||
const host = makeHost({ ok: true });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept a non-vault_locked error', async () => {
|
||||
const host = makeHost({ ok: false, error: 'something_else' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the response unchanged', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
const resp = await sendMessage({ type: 'list_items' });
|
||||
expect(resp).toEqual({ ok: false, error: 'vault_locked' });
|
||||
});
|
||||
});
|
||||
92
extension/src/shared/__tests__/state.test.ts
Normal file
92
extension/src/shared/__tests__/state.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
registerHost,
|
||||
__resetHostForTests,
|
||||
getState,
|
||||
setState,
|
||||
navigate,
|
||||
sendMessage,
|
||||
} from '../state';
|
||||
import type { StateHost } from '../state';
|
||||
import type { PopupState } from '../popup-state';
|
||||
|
||||
function makeHost(initial?: Partial<PopupState>): StateHost {
|
||||
let state: PopupState = {
|
||||
view: 'list',
|
||||
entries: [],
|
||||
selectedId: null,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
capturedTabId: null,
|
||||
capturedUrl: '',
|
||||
newType: null,
|
||||
vaultSettings: null,
|
||||
generatorDefaults: null,
|
||||
historyItemId: null,
|
||||
...initial,
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
setState: (partial) => { state = { ...state, ...partial }; },
|
||||
navigate: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue({ ok: true }),
|
||||
escapeHtml: (s) => s,
|
||||
popOutToTab: vi.fn(),
|
||||
isInTab: () => false,
|
||||
openVaultTab: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('shared/state', () => {
|
||||
beforeEach(() => {
|
||||
__resetHostForTests();
|
||||
});
|
||||
|
||||
it('register-then-getState round-trips', () => {
|
||||
const host = makeHost({ view: 'detail' });
|
||||
registerHost(host);
|
||||
expect(getState().view).toBe('detail');
|
||||
});
|
||||
|
||||
it('double-register throws', () => {
|
||||
registerHost(makeHost());
|
||||
expect(() => registerHost(makeHost())).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it('__resetHostForTests clears the singleton', () => {
|
||||
registerHost(makeHost());
|
||||
__resetHostForTests();
|
||||
expect(() => getState()).toThrow(/No state host/);
|
||||
});
|
||||
|
||||
it('getState without host throws', () => {
|
||||
expect(() => getState()).toThrow(/No state host/);
|
||||
});
|
||||
|
||||
it('setState merges partial state', () => {
|
||||
const host = makeHost();
|
||||
registerHost(host);
|
||||
setState({ loading: true });
|
||||
expect(getState().loading).toBe(true);
|
||||
});
|
||||
|
||||
it('navigate delegates to host', () => {
|
||||
const host = makeHost();
|
||||
registerHost(host);
|
||||
navigate('settings');
|
||||
expect(host.navigate).toHaveBeenCalledWith('settings', undefined);
|
||||
});
|
||||
|
||||
it('sendMessage delegates to host', async () => {
|
||||
const host = makeHost();
|
||||
registerHost(host);
|
||||
const resp = await sendMessage({ type: 'is_unlocked' });
|
||||
expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' });
|
||||
expect(resp).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,11 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
||||
export const GLYPH_COPY = '⎘'; // copy to clipboard
|
||||
export const GLYPH_SYNC = '⇅'; // sync / upload
|
||||
export const GLYPH_REFRESH = '↻'; // manual refresh (vault status indicator); shares ↻ with GENERATE, distinct semantic
|
||||
export const GLYPH_SYNCED = '✓'; // vault status: in sync (no pending/ahead/behind)
|
||||
export const GLYPH_AHEAD = '↑'; // vault status: local commits ahead of remote
|
||||
export const GLYPH_BEHIND = '↓'; // vault status: remote commits not yet pulled
|
||||
export const GLYPH_PENDING = '◌'; // vault status: items changed but not yet synced
|
||||
export const GLYPH_PREVIEW = '⊕'; // preview / expand
|
||||
|
||||
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
|
||||
|
||||
@@ -63,7 +63,12 @@ export type PopupMessage =
|
||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
||||
| { type: 'generate_recovery_qr'; passphrase: string }
|
||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
|
||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string }
|
||||
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
|
||||
carrierImageBytes: ArrayBuffer; deviceName: string }
|
||||
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
|
||||
referenceImageBytes: ArrayBuffer; deviceName: string }
|
||||
| { type: 'get_vault_status' };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -176,6 +181,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||
'preview_totp_from_secret',
|
||||
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||
'create_vault', 'attach_vault', 'get_vault_status',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||
@@ -201,6 +207,20 @@ export interface ImportLastPassCommitResponse extends Extract<Response, { ok: tr
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateVaultResponse extends Extract<Response, { ok: true }> {
|
||||
data: { referenceImageBytes: Uint8Array; deviceName: string;
|
||||
recoveryQrAvailable: true };
|
||||
}
|
||||
|
||||
export interface AttachVaultResponse extends Extract<Response, { ok: true }> {
|
||||
data: { deviceName: string };
|
||||
}
|
||||
|
||||
export interface GetVaultStatusResponse extends Extract<Response, { ok: true }> {
|
||||
data: { ahead: number; behind: number; lastSyncAt: number | null;
|
||||
pendingItems: number };
|
||||
}
|
||||
|
||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||||
'capture_save_login',
|
||||
|
||||
57
extension/src/shared/popup-state.ts
Normal file
57
extension/src/shared/popup-state.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// State shared between popup and vault surfaces. Kept here (not in popup/) so
|
||||
// shared/state.ts can import without creating a popup→shared circular dep.
|
||||
|
||||
import type {
|
||||
Item,
|
||||
ItemId,
|
||||
ItemType,
|
||||
ManifestEntry,
|
||||
GeneratorRequest,
|
||||
VaultSettings,
|
||||
} from './types';
|
||||
|
||||
export type View =
|
||||
| 'locked'
|
||||
| 'list'
|
||||
| 'detail'
|
||||
| 'add'
|
||||
| 'edit'
|
||||
| 'settings'
|
||||
| 'settings-vault'
|
||||
| 'trash'
|
||||
| 'devices'
|
||||
| 'field-history'
|
||||
// Vault-tab-only views; popup never navigates to these. Kept in the union so
|
||||
// a single typed StateHost contract serves both surfaces (popup + vault).
|
||||
| 'history'
|
||||
| 'backup'
|
||||
| 'import';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
// Captured tab snapshot taken at popup-open. Used by fill_credentials
|
||||
// to guard against TOCTOU navigation — the SW re-checks this URL's
|
||||
// hostname against the tab's live URL before forwarding fill_credentials
|
||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
newType: ItemType | null;
|
||||
vaultSettings: VaultSettings | null;
|
||||
generatorDefaults: GeneratorRequest | null;
|
||||
historyItemId: ItemId | null;
|
||||
// Vault-tab-only fields. The popup surface leaves these at their defaults
|
||||
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
|
||||
// Kept on the shared shape so VaultState satisfies StateHost.getState()
|
||||
// without a cast.
|
||||
unlocked?: boolean;
|
||||
drawerOpen?: boolean;
|
||||
typePanelOpen?: boolean;
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
/// Service-locator for cross-bundle state access.
|
||||
///
|
||||
/// Both popup.ts and vault.ts register themselves as the "host".
|
||||
/// All popup components import from here instead of from popup.ts,
|
||||
/// so the same component code works in either bundle.
|
||||
// extension/src/shared/state.ts
|
||||
//
|
||||
// Single channel for popup and vault-tab UI to read/write app state and
|
||||
// dispatch messages to the service worker. Two registered hosts (popup,
|
||||
// vault tab) implement StateHost; each surface calls registerHost(this) at
|
||||
// boot.
|
||||
//
|
||||
// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts
|
||||
// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature;
|
||||
// the body is a thin pass-through until Phase 4.
|
||||
|
||||
import type { Request, Response } from './messages';
|
||||
import type { PopupState, View } from './popup-state';
|
||||
|
||||
export interface StateHost {
|
||||
getState(): any;
|
||||
setState(partial: any): void;
|
||||
navigate(view: string, extras?: any): void;
|
||||
getState(): PopupState;
|
||||
setState(partial: Partial<PopupState>): void;
|
||||
navigate(view: View, extras?: Partial<PopupState>): void;
|
||||
sendMessage(request: Request): Promise<Response>;
|
||||
escapeHtml(s: string): string;
|
||||
popOutToTab(): void;
|
||||
@@ -19,26 +25,58 @@ export interface StateHost {
|
||||
|
||||
let host: StateHost | null = null;
|
||||
|
||||
export function registerHost(h: StateHost): void { host = h; }
|
||||
export function registerHost(h: StateHost): void {
|
||||
if (host) throw new Error('state host already registered');
|
||||
host = h;
|
||||
}
|
||||
|
||||
export function getState(): any {
|
||||
/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */
|
||||
export function __resetHostForTests(): void {
|
||||
host = null;
|
||||
}
|
||||
|
||||
export function getState(): PopupState {
|
||||
if (!host) throw new Error('No state host registered');
|
||||
return host.getState();
|
||||
}
|
||||
|
||||
export function setState(partial: any): void {
|
||||
export function setState(partial: Partial<PopupState>): void {
|
||||
if (!host) throw new Error('No state host registered');
|
||||
host.setState(partial);
|
||||
}
|
||||
|
||||
export function navigate(view: string, extras?: any): void {
|
||||
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||
if (!host) throw new Error('No state host registered');
|
||||
host.navigate(view, extras);
|
||||
}
|
||||
|
||||
export function sendMessage(request: Request): Promise<Response> {
|
||||
// Requests that must NOT trigger the lock screen on a vault_locked response:
|
||||
// they run during cold start / unlock, before a session exists, so a
|
||||
// vault_locked here is expected rather than a lost session.
|
||||
const VAULT_LOCKED_BYPASS: ReadonlySet<Request['type']> = new Set([
|
||||
'unlock', 'is_unlocked',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dispatches a request to the service worker and intercepts the `vault_locked`
|
||||
* response. MV3 evicts the service worker after ~30s idle, wiping the in-memory
|
||||
* session/manifest; the next RPC comes back `vault_locked`. Any surface (popup
|
||||
* or vault tab) that gets that on a non-bypassed request treats it as "session
|
||||
* lost" and navigates to the lock screen so the user can re-enter their
|
||||
* passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4
|
||||
* so both surfaces share one channel.
|
||||
*/
|
||||
export async function sendMessage(request: Request): Promise<Response> {
|
||||
if (!host) throw new Error('No state host registered');
|
||||
return host.sendMessage(request);
|
||||
const response = await host.sendMessage(request);
|
||||
if (
|
||||
!response.ok &&
|
||||
response.error === 'vault_locked' &&
|
||||
!VAULT_LOCKED_BYPASS.has(request.type)
|
||||
) {
|
||||
host.navigate('locked', { error: 'Session expired — please unlock again.' });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
@@ -52,7 +90,7 @@ export function popOutToTab(): void {
|
||||
}
|
||||
|
||||
export function isInTab(): boolean {
|
||||
if (!host) return false;
|
||||
if (!host) throw new Error('No state host registered');
|
||||
return host.isInTab();
|
||||
}
|
||||
|
||||
|
||||
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ensureDrawerClosedForRoute } from '../vault-drawer';
|
||||
|
||||
describe('ensureDrawerClosedForRoute', () => {
|
||||
it('closes the drawer when navigating to trash', () => {
|
||||
const state = { drawerOpen: true };
|
||||
ensureDrawerClosedForRoute(state, { view: 'trash' });
|
||||
expect(state.drawerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the drawer open when navigating to detail', () => {
|
||||
const state = { drawerOpen: true };
|
||||
ensureDrawerClosedForRoute(state, { view: 'detail' });
|
||||
expect(state.drawerOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the drawer open in list view', () => {
|
||||
const state = { drawerOpen: true };
|
||||
ensureDrawerClosedForRoute(state, { view: 'list' });
|
||||
expect(state.drawerOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the drawer is already closed', () => {
|
||||
const state = { drawerOpen: false };
|
||||
ensureDrawerClosedForRoute(state, { view: 'devices' });
|
||||
expect(state.drawerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
|
||||
describe('fullscreen form dirty subtitle', () => {
|
||||
const vaultSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, '../vault.ts'),
|
||||
path.resolve(__dirname, '../vault-form-wrapper.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
|
||||
describe('vault sidebar glyphs', () => {
|
||||
const vaultSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, '../vault.ts'),
|
||||
path.resolve(__dirname, '../vault-sidebar.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
|
||||
34
extension/src/vault/__tests__/status-indicator.test.ts
Normal file
34
extension/src/vault/__tests__/status-indicator.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderStatusIndicator } from '../vault-status';
|
||||
|
||||
describe('vault status indicator', () => {
|
||||
it('renders "in sync" when ahead/behind/pending all zero', () => {
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 });
|
||||
expect(el.textContent).toMatch(/in sync/i);
|
||||
});
|
||||
|
||||
it('renders "N ahead" when ahead is non-zero', () => {
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, { ahead: 3, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 });
|
||||
expect(el.textContent).toMatch(/3 ahead/i);
|
||||
});
|
||||
|
||||
it('renders "N behind" when behind is non-zero', () => {
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, { ahead: 0, behind: 2, lastSyncAt: 1700000000, pendingItems: 0 });
|
||||
expect(el.textContent).toMatch(/2 behind/i);
|
||||
});
|
||||
|
||||
it('renders "N pending" when pendingItems is non-zero', () => {
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 5 });
|
||||
expect(el.textContent).toMatch(/5 pending/i);
|
||||
});
|
||||
|
||||
it('renders "never synced" when lastSyncAt is null', () => {
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 });
|
||||
expect(el.textContent).toMatch(/never synced/i);
|
||||
});
|
||||
});
|
||||
65
extension/src/vault/__tests__/status-integration.test.ts
Normal file
65
extension/src/vault/__tests__/status-integration.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { handleGetVaultStatus } from '../../service-worker/vault';
|
||||
import { renderStatusIndicator } from '../vault-status';
|
||||
import type { Manifest, ManifestEntry } from '../../shared/types';
|
||||
|
||||
// Integration seam: the get_vault_status SW handler (Phase 6 Task 6.1) produces
|
||||
// the exact data shape the sidebar renderer (Task 6.2) consumes. This pins the
|
||||
// contract between the two so a future change to either side can't silently
|
||||
// drift the keys apart. It does NOT touch vault-sidebar.ts — the wiring layer
|
||||
// (Task 6.3) is Dev-B's boundary and lands separately.
|
||||
|
||||
const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) =>
|
||||
({ lastSyncAt, ahead, behind });
|
||||
|
||||
function manifestWith(activeCount: number, trashedCount = 0): Manifest {
|
||||
const items: Record<string, ManifestEntry> = {};
|
||||
for (let i = 0; i < activeCount; i++) {
|
||||
items[`a${i}`] = { trashed_at: undefined } as ManifestEntry;
|
||||
}
|
||||
for (let i = 0; i < trashedCount; i++) {
|
||||
items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry;
|
||||
}
|
||||
return { items } as Manifest;
|
||||
}
|
||||
|
||||
describe('vault status: handler → renderer integration', () => {
|
||||
it('renders "in sync" from a freshly-synced, no-pending handler response', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(0) });
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, resp.data);
|
||||
expect(el.textContent).toMatch(/in sync/i);
|
||||
expect(el.textContent).toMatch(/last sync/i);
|
||||
});
|
||||
|
||||
it('surfaces the handler\'s active-item count as "N pending" in the DOM', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(7, 3) });
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
expect(resp.data.pendingItems).toBe(7);
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, resp.data);
|
||||
expect(el.textContent).toMatch(/7 pending/i);
|
||||
});
|
||||
|
||||
it('surfaces cached ahead/behind from the handler in the DOM', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(1700000000, 2, 1), manifest: manifestWith(0) });
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, resp.data);
|
||||
expect(el.textContent).toMatch(/2 ahead/i);
|
||||
expect(el.textContent).toMatch(/1 behind/i);
|
||||
});
|
||||
|
||||
it('renders "never synced" when the handler reports a null lastSyncAt', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: manifestWith(0) });
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const el = document.createElement('div');
|
||||
renderStatusIndicator(el, resp.data);
|
||||
expect(el.textContent).toMatch(/never synced/i);
|
||||
});
|
||||
});
|
||||
62
extension/src/vault/__tests__/vault-sidebar-status.test.ts
Normal file
62
extension/src/vault/__tests__/vault-sidebar-status.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderSidebarShell, wireSidebar } from '../vault-sidebar';
|
||||
import type { VaultController } from '../vault-context';
|
||||
|
||||
const STATUS = { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 4 };
|
||||
|
||||
function makeCtx() {
|
||||
return {
|
||||
state: { searchQuery: '' },
|
||||
sendMessage: vi.fn(async (req: { type: string }) =>
|
||||
req.type === 'get_vault_status'
|
||||
? { ok: true, data: STATUS }
|
||||
: { ok: true }),
|
||||
render: vi.fn(),
|
||||
renderPane: vi.fn(),
|
||||
renderListPane: vi.fn(),
|
||||
closeDrawer: vi.fn(),
|
||||
openTypePanel: vi.fn(),
|
||||
setHash: vi.fn(),
|
||||
applyShellViewClass: vi.fn(),
|
||||
} as unknown as VaultController;
|
||||
}
|
||||
|
||||
function statusCalls(ctx: VaultController): number {
|
||||
return (ctx.sendMessage as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter((c) => (c[0] as { type: string }).type === 'get_vault_status').length;
|
||||
}
|
||||
|
||||
describe('vault-sidebar status wiring', () => {
|
||||
beforeEach(() => { document.body.innerHTML = renderSidebarShell(); });
|
||||
afterEach(() => { document.body.innerHTML = ''; });
|
||||
|
||||
it('fetches + renders the indicator on mount', async () => {
|
||||
const ctx = makeCtx();
|
||||
wireSidebar(ctx);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.getElementById('vault-status-slot')?.textContent).toMatch(/4 pending/i);
|
||||
});
|
||||
expect(statusCalls(ctx)).toBe(1);
|
||||
});
|
||||
|
||||
it('re-fetches on the manual refresh button', async () => {
|
||||
const ctx = makeCtx();
|
||||
wireSidebar(ctx);
|
||||
await vi.waitFor(() => expect(statusCalls(ctx)).toBe(1));
|
||||
document.getElementById('status-refresh-btn')?.dispatchEvent(new Event('click'));
|
||||
await vi.waitFor(() => expect(statusCalls(ctx)).toBe(2));
|
||||
});
|
||||
|
||||
it('does NOT poll on a timer', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const ctx = makeCtx();
|
||||
wireSidebar(ctx);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
// Only the single mount fetch — no interval re-fetches.
|
||||
expect(statusCalls(ctx)).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
122
extension/src/vault/vault-context.ts
Normal file
122
extension/src/vault/vault-context.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Shared contract for the vault-tab modules. vault.ts owns the state
|
||||
// singleton and assembles the VaultController; each vault-* module receives
|
||||
// it as `ctx`. This module sits at the bottom of the dependency graph —
|
||||
// it imports only from shared/, never from vault.ts or its sibling modules.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type {
|
||||
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
||||
} from '../shared/types';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||
} from '../shared/glyphs';
|
||||
|
||||
export type VaultView =
|
||||
| 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings'
|
||||
| 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import';
|
||||
|
||||
export interface HashRoute {
|
||||
view: VaultView;
|
||||
id?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface VaultState {
|
||||
unlocked: boolean;
|
||||
view: VaultView;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
drawerOpen: boolean;
|
||||
typePanelOpen: boolean;
|
||||
vaultSettings: VaultSettings | null;
|
||||
generatorDefaults: GeneratorRequest | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
newType: ItemType | null;
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
historyItemId: ItemId | null;
|
||||
}
|
||||
|
||||
// The controller passed to every vault-* module. vault.ts builds one instance
|
||||
// and wires each hook to the function that currently lives in vault.ts (later
|
||||
// Phase-4 tasks repoint individual hooks at the extracted module functions).
|
||||
export interface VaultController {
|
||||
readonly state: VaultState;
|
||||
sendMessage(request: Request): Promise<Response>;
|
||||
render(): void;
|
||||
renderPane(): void;
|
||||
renderListPane(): void;
|
||||
renderSidebarCategories(): void;
|
||||
renderDrawer(item: Item): void;
|
||||
applyShellViewClass(): void;
|
||||
setHash(view: VaultView, param?: string): void;
|
||||
openDrawer(): void;
|
||||
closeDrawer(): void;
|
||||
selectItemForDrawer(id: string): Promise<void>;
|
||||
openTypePanel(): void;
|
||||
closeTypePanel(): void;
|
||||
wireSidebar(): void;
|
||||
loadManifest(): Promise<void>;
|
||||
}
|
||||
|
||||
// --- pure helpers (no state, no DOM dependencies beyond the args) ---
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return GLYPH_TYPE_LOGIN;
|
||||
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||
case 'card': return GLYPH_TYPE_CARD;
|
||||
case 'key': return GLYPH_TYPE_KEY;
|
||||
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||
case 'totp': return GLYPH_TYPE_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
export function typeLabel(t: ItemType): string {
|
||||
const labels: Record<ItemType, string> = {
|
||||
login: 'Login',
|
||||
secure_note: 'Secure Note',
|
||||
identity: 'Identity',
|
||||
card: 'Card',
|
||||
key: 'SSH / API Key',
|
||||
document: 'Document',
|
||||
totp: 'TOTP',
|
||||
};
|
||||
return labels[t];
|
||||
}
|
||||
|
||||
export function getFilteredEntries(
|
||||
state: VaultState,
|
||||
): Array<[ItemId, ManifestEntry]> {
|
||||
let filtered = state.entries.filter(
|
||||
([, e]) => e.trashed_at === undefined || e.trashed_at === null,
|
||||
);
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(([, e]) => {
|
||||
if (e.title.toLowerCase().includes(q)) return true;
|
||||
if (e.icon_hint?.toLowerCase().includes(q)) return true;
|
||||
if (e.group?.toLowerCase().includes(q)) return true;
|
||||
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||
return filtered;
|
||||
}
|
||||
138
extension/src/vault/vault-drawer.ts
Normal file
138
extension/src/vault/vault-drawer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Vault-tab drawer: the right-hand overlay that previews a selected item
|
||||
// (open/close/render + item selection). Receives the VaultController (`ctx`)
|
||||
// and reaches sibling concerns through it; pure helpers come from
|
||||
// vault-context. Imports only from shared/ and vault-context.
|
||||
|
||||
import type { Item } from '../shared/types';
|
||||
import {
|
||||
type VaultController, type VaultState, type HashRoute, escapeHtml,
|
||||
} from './vault-context';
|
||||
|
||||
export function openDrawer(): void {
|
||||
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
|
||||
}
|
||||
|
||||
export function closeDrawer(ctx: VaultController): void {
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
|
||||
}
|
||||
|
||||
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
|
||||
const core = item.core as unknown as Record<string, unknown>;
|
||||
if (!core) return [];
|
||||
const fields: Array<[string, string, boolean]> = [];
|
||||
|
||||
switch (item.type) {
|
||||
case 'login':
|
||||
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
|
||||
if ('password' in core) fields.push(['password', '••••••••', false]);
|
||||
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
|
||||
break;
|
||||
case 'card': {
|
||||
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
|
||||
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
|
||||
if ('expiry' in core && core.expiry) {
|
||||
const exp = core.expiry as { month: number; year: number };
|
||||
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
|
||||
}
|
||||
if ('cvv' in core) fields.push(['cvv', '•••', false]);
|
||||
if ('pin' in core) fields.push(['pin', '••••', false]);
|
||||
break;
|
||||
}
|
||||
case 'identity':
|
||||
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
|
||||
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
|
||||
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
|
||||
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
|
||||
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
|
||||
break;
|
||||
case 'key':
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
|
||||
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
|
||||
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
|
||||
break;
|
||||
case 'secure_note':
|
||||
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
|
||||
break;
|
||||
case 'totp':
|
||||
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
|
||||
break;
|
||||
case 'document':
|
||||
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
|
||||
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.notes) fields.push(['notes', item.notes, true]);
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function renderDrawer(ctx: VaultController, item: Item): void {
|
||||
const drawer = document.getElementById('vault-drawer');
|
||||
if (!drawer) return;
|
||||
|
||||
const coreFields = getDrawerCoreFields(item);
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="vault-drawer__header">
|
||||
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
||||
<div class="vault-drawer__actions">
|
||||
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
||||
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-drawer__body">
|
||||
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
||||
${item.type === 'login' && (item.core as { url?: string }).url
|
||||
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
||||
: ''}
|
||||
<div class="vault-drawer__field-grid">
|
||||
${coreFields.map(([label, value, full]) => `
|
||||
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
||||
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
||||
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
|
||||
closeDrawer(ctx);
|
||||
ctx.renderListPane();
|
||||
});
|
||||
|
||||
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
|
||||
if (ctx.state.selectedId) {
|
||||
ctx.setHash('edit', ctx.state.selectedId);
|
||||
ctx.renderPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectItemForDrawer(ctx: VaultController, id: string): Promise<void> {
|
||||
const resp = await ctx.sendMessage({ type: 'get_item', id });
|
||||
if (!resp.ok) return;
|
||||
const data = resp.data as { item: Item };
|
||||
ctx.state.selectedId = id;
|
||||
ctx.state.selectedItem = data.item;
|
||||
ctx.state.drawerOpen = true;
|
||||
ctx.renderSidebarCategories();
|
||||
ctx.renderListPane();
|
||||
renderDrawer(ctx, data.item);
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
// Drawer is an overlay only meaningful on the list/detail surfaces; any
|
||||
// other route must clear it so it doesn't leak across navigation (P2 fix).
|
||||
const DRAWER_KEEPING_VIEWS: ReadonlySet<string> = new Set(['list', 'detail']);
|
||||
|
||||
export function ensureDrawerClosedForRoute(
|
||||
state: Pick<VaultState, 'drawerOpen'>,
|
||||
route: Pick<HashRoute, 'view'>,
|
||||
): void {
|
||||
if (!DRAWER_KEEPING_VIEWS.has(route.view)) state.drawerOpen = false;
|
||||
}
|
||||
72
extension/src/vault/vault-form-wrapper.ts
Normal file
72
extension/src/vault/vault-form-wrapper.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Fullscreen form wrapper for the vault tab: sticky save bar + scrollable
|
||||
// content + header with a live dirty-state subtitle. Receives the
|
||||
// VaultController (`ctx`) for the item-type read; imports only from shared/,
|
||||
// the popup item-form component, and vault-context.
|
||||
|
||||
import { renderItemForm } from '../popup/components/item-form';
|
||||
import { type VaultController } from './vault-context';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-aware save hint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||
const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen form wrapper — sticky save bar + scrollable content + header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderFormWrapped(ctx: VaultController, app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const itemType = ctx.state.selectedItem?.type ?? ctx.state.newType ?? 'login';
|
||||
const typeLabelText = itemType.replace('_', ' ');
|
||||
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-pane';
|
||||
wrapper.innerHTML = `
|
||||
<div class="fullscreen-form-header">
|
||||
<div>
|
||||
<div class="title">${titleText}</div>
|
||||
<div class="sub" id="form-dirty-sub">no changes</div>
|
||||
</div>
|
||||
<div class="hint">${SAVE_HINT}</div>
|
||||
</div>
|
||||
<div class="form-scroll" id="form-scroll"></div>
|
||||
<div class="sticky-save-bar">
|
||||
<button class="btn-secondary" id="form-cancel">cancel</button>
|
||||
<button class="btn-primary" id="form-save">save</button>
|
||||
</div>
|
||||
`;
|
||||
// Remove pane padding so form-pane can fill height cleanly
|
||||
app.style.padding = '0';
|
||||
app.style.overflow = 'hidden';
|
||||
app.replaceChildren(wrapper);
|
||||
|
||||
const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
|
||||
renderItemForm(scrollEl, mode);
|
||||
|
||||
const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
|
||||
let isDirty = false;
|
||||
const markDirty = () => {
|
||||
if (isDirty) return;
|
||||
isDirty = true;
|
||||
subEl.textContent = 'unsaved · esc to cancel';
|
||||
};
|
||||
const markClean = () => {
|
||||
isDirty = false;
|
||||
subEl.textContent = 'no changes';
|
||||
};
|
||||
scrollEl.addEventListener('input', markDirty, true);
|
||||
scrollEl.addEventListener('change', markDirty, true);
|
||||
|
||||
wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
|
||||
markClean();
|
||||
(scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
wrapper.querySelector('#form-save')?.addEventListener('click', () => {
|
||||
markClean();
|
||||
(scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
}
|
||||
|
||||
export const __test__ = { renderFormWrapped };
|
||||
52
extension/src/vault/vault-list.ts
Normal file
52
extension/src/vault/vault-list.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Vault-tab list column: renders the middle list pane (row markup, empty
|
||||
// state, and the row-click → drawer selection). Receives the VaultController
|
||||
// (`ctx`) and reaches sibling concerns through it; pure helpers come from
|
||||
// vault-context. Imports only from shared/ and vault-context.
|
||||
|
||||
import type { ItemId, ManifestEntry, ItemType } from '../shared/types';
|
||||
import { relativeTime } from '../shared/relative-time';
|
||||
import {
|
||||
type VaultController, escapeHtml, typeIcon, getFilteredEntries,
|
||||
} from './vault-context';
|
||||
|
||||
export function renderListPane(ctx: VaultController): void {
|
||||
const pane = document.getElementById('vault-list-pane');
|
||||
if (!pane) return;
|
||||
|
||||
const group = ctx.state.activeGroup as ItemType | null;
|
||||
let items = getFilteredEntries(ctx.state);
|
||||
if (group) items = items.filter(([, e]) => e.type === group);
|
||||
|
||||
if (items.length === 0) {
|
||||
pane.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">${ctx.state.searchQuery ? '⊘' : '◈'}</span>
|
||||
<div class="empty-state__title">${ctx.state.searchQuery ? `No results for "${escapeHtml(ctx.state.searchQuery)}"` : 'No items yet'}</div>
|
||||
<div class="empty-state__hint">${ctx.state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
pane.innerHTML = items.map(([id, e]: [ItemId, ManifestEntry]) => {
|
||||
const sel = id === ctx.state.selectedId ? ' vault-list-row--selected' : '';
|
||||
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
|
||||
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
|
||||
return `
|
||||
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
|
||||
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
|
||||
<div class="vault-list-row__text">
|
||||
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
|
||||
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
||||
</div>
|
||||
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
|
||||
row.addEventListener('click', async () => {
|
||||
await ctx.selectItemForDrawer(row.dataset.id!);
|
||||
});
|
||||
});
|
||||
}
|
||||
205
extension/src/vault/vault-router.ts
Normal file
205
extension/src/vault/vault-router.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// Vault-tab routing core: hash parsing/serialization, pane dispatch (delegating
|
||||
// to the shared popup components), and data loading. Receives the
|
||||
// VaultController (`ctx`) and reaches sibling concerns through it. Imports only
|
||||
// from shared/, the popup components, vault-context, vault-drawer, and
|
||||
// vault-form-wrapper — never from vault.ts or the shell/sidebar/list modules.
|
||||
|
||||
import type {
|
||||
ItemId, ItemType, ManifestEntry, Item, VaultSettings,
|
||||
} from '../shared/types';
|
||||
import { renderItemDetail } from '../popup/components/item-detail';
|
||||
import { renderItemForm } from '../popup/components/item-form';
|
||||
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
||||
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
|
||||
import { renderSettings, teardownSettings } from '../popup/components/settings';
|
||||
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||
import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index';
|
||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||
import {
|
||||
type VaultController, type VaultView, type HashRoute,
|
||||
} from './vault-context';
|
||||
import { ensureDrawerClosedForRoute } from './vault-drawer';
|
||||
import { renderFormWrapped } from './vault-form-wrapper';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseHash(): HashRoute {
|
||||
let raw = window.location.hash.replace(/^#\/?/, '');
|
||||
if (!raw) return { view: 'list' };
|
||||
|
||||
// Normalize legacy bookmarks: #field-history/<id> → #history/<id>
|
||||
if (raw.startsWith('field-history/')) {
|
||||
raw = 'history/' + raw.slice('field-history/'.length);
|
||||
window.location.hash = raw;
|
||||
}
|
||||
|
||||
const parts = raw.split('/');
|
||||
const view = parts[0] as VaultView;
|
||||
|
||||
switch (view) {
|
||||
case 'detail':
|
||||
case 'edit':
|
||||
return { view, id: parts[1] };
|
||||
case 'add':
|
||||
return { view, type: parts[1] };
|
||||
case 'history':
|
||||
return parts[1]
|
||||
? { view: 'field-history', id: parts[1] }
|
||||
: { view: 'history' };
|
||||
case 'trash':
|
||||
case 'devices':
|
||||
case 'settings':
|
||||
case 'settings-vault':
|
||||
case 'field-history':
|
||||
case 'backup':
|
||||
case 'import':
|
||||
return { view };
|
||||
default:
|
||||
return { view: 'list' };
|
||||
}
|
||||
}
|
||||
|
||||
export function setHash(view: VaultView, param?: string): void {
|
||||
const fragment = param ? `${view}/${param}` : view;
|
||||
window.location.hash = fragment === 'list' ? '' : fragment;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane rendering — delegates to shared popup components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function teardownPaneComponents(): void {
|
||||
teardownTrash();
|
||||
teardownDevices();
|
||||
teardownSettings();
|
||||
teardownFieldHistory();
|
||||
teardownHistoryIndex();
|
||||
teardownBackup();
|
||||
teardownImport();
|
||||
}
|
||||
|
||||
export function renderPane(ctx: VaultController): void {
|
||||
const pane = document.getElementById('vault-pane');
|
||||
if (!pane) return;
|
||||
|
||||
teardownPaneComponents();
|
||||
|
||||
const route = parseHash();
|
||||
ensureDrawerClosedForRoute(ctx.state, route);
|
||||
// Keep state.view in sync with hash for components that read it
|
||||
ctx.state.view = route.view;
|
||||
ctx.applyShellViewClass();
|
||||
|
||||
pane.className = 'vault-pane';
|
||||
|
||||
switch (route.view) {
|
||||
case 'detail':
|
||||
if (ctx.state.selectedItem) {
|
||||
renderItemDetail(pane);
|
||||
} else {
|
||||
pane.className = 'vault-pane vault-pane--empty';
|
||||
pane.innerHTML = 'select an item';
|
||||
}
|
||||
break;
|
||||
case 'add':
|
||||
// Prefer hash type for deep-links; otherwise keep the in-memory value
|
||||
// set by the type-selection click handler (which calls setState →
|
||||
// renderPane before the URL hash has been updated to include the type).
|
||||
ctx.state.newType = (route.type as ItemType) ?? ctx.state.newType ?? null;
|
||||
// Use the form wrapper (sticky bar + header) when a type is already chosen.
|
||||
// Without a type the type-selection screen renders — no sticky bar needed.
|
||||
if (ctx.state.newType) {
|
||||
renderFormWrapped(ctx, pane, 'add');
|
||||
} else {
|
||||
renderItemForm(pane, 'add');
|
||||
}
|
||||
break;
|
||||
case 'edit':
|
||||
renderFormWrapped(ctx, pane, 'edit');
|
||||
break;
|
||||
case 'trash':
|
||||
renderTrash(pane);
|
||||
break;
|
||||
case 'devices':
|
||||
renderDevices(pane);
|
||||
break;
|
||||
case 'settings':
|
||||
void renderSettings(pane);
|
||||
break;
|
||||
case 'settings-vault':
|
||||
renderVaultSettingsView(pane);
|
||||
break;
|
||||
case 'field-history':
|
||||
renderFieldHistory(pane);
|
||||
break;
|
||||
case 'history':
|
||||
renderItemHistoryIndex(pane);
|
||||
break;
|
||||
case 'backup':
|
||||
renderBackupPanel(pane);
|
||||
break;
|
||||
case 'import':
|
||||
renderImportPanel(pane);
|
||||
break;
|
||||
default:
|
||||
pane.className = 'vault-pane vault-pane--empty';
|
||||
pane.innerHTML = 'select an item';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadManifest(ctx: VaultController): Promise<void> {
|
||||
const listResp = await ctx.sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
ctx.state.entries = data.items;
|
||||
}
|
||||
|
||||
const vsResp = await ctx.sendMessage({ type: 'get_vault_settings' });
|
||||
if (vsResp.ok) {
|
||||
const data = vsResp.data as { settings: VaultSettings };
|
||||
ctx.state.vaultSettings = data.settings;
|
||||
ctx.state.generatorDefaults = data.settings.generator_defaults;
|
||||
}
|
||||
|
||||
// Handle deep link from hash
|
||||
const route = parseHash();
|
||||
if (route.view === 'detail' && route.id) {
|
||||
const itemResp = await ctx.sendMessage({ type: 'get_item', id: route.id });
|
||||
if (itemResp.ok) {
|
||||
const data = itemResp.data as { item: Item };
|
||||
ctx.state.selectedId = route.id;
|
||||
ctx.state.selectedItem = data.item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy selectItem — used by hash-change deep linking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function selectItem(ctx: VaultController, id: ItemId): Promise<void> {
|
||||
ctx.state.loading = true;
|
||||
const resp = await ctx.sendMessage({ type: 'get_item', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { item: Item };
|
||||
ctx.state.selectedId = id;
|
||||
ctx.state.selectedItem = data.item;
|
||||
ctx.state.loading = false;
|
||||
setHash('detail', id);
|
||||
ctx.renderSidebarCategories();
|
||||
ctx.renderListPane();
|
||||
renderPane(ctx);
|
||||
} else {
|
||||
ctx.state.loading = false;
|
||||
ctx.state.error = (resp as { error: string }).error;
|
||||
}
|
||||
}
|
||||
236
extension/src/vault/vault-shell.ts
Normal file
236
extension/src/vault/vault-shell.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Vault-tab shell: render entry point, lock screen, 3-column shell
|
||||
// scaffolding, the right-side type-picker panel, color-scheme apply, and the
|
||||
// session_expired listener. Each function receives the VaultController (`ctx`)
|
||||
// and reaches sibling concerns through it; pure helpers come from
|
||||
// vault-context. vault.ts owns the state singleton and assembles the ctx.
|
||||
|
||||
import type { ItemType } from '../shared/types';
|
||||
import { lookupErrorCopy } from '../shared/error-copy';
|
||||
import { applyColorScheme } from '../shared/color-scheme';
|
||||
import {
|
||||
type VaultController, escapeHtml, typeIcon,
|
||||
} from './vault-context';
|
||||
import { renderSidebarShell } from './vault-sidebar';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type picker (right side panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
|
||||
{ type: 'login', label: 'Login' },
|
||||
{ type: 'secure_note', label: 'Secure Note' },
|
||||
{ type: 'totp', label: 'TOTP' },
|
||||
{ type: 'card', label: 'Card' },
|
||||
{ type: 'identity', label: 'Identity' },
|
||||
{ type: 'key', label: 'SSH / API Key' },
|
||||
{ type: 'document', label: 'Document' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function render(ctx: VaultController): void {
|
||||
const app = document.getElementById('vault-app');
|
||||
if (!app) return;
|
||||
|
||||
if (!ctx.state.unlocked) {
|
||||
renderLockScreen(ctx, app);
|
||||
} else {
|
||||
renderShell(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lock screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderErrorBlock(code: string | null | undefined): string {
|
||||
if (!code) return '';
|
||||
const copy = lookupErrorCopy(code);
|
||||
const ctaHtml = copy.cta
|
||||
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="error error-block">
|
||||
<div class="error-title">${escapeHtml(copy.title)}</div>
|
||||
<div class="error-body">${escapeHtml(copy.body)}</div>
|
||||
${ctaHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderLockScreen(ctx: VaultController, app: HTMLElement): void {
|
||||
app.innerHTML = `
|
||||
<div class="vault-lock-screen">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
<div class="vault-lock-screen__form">
|
||||
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
||||
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
||||
${renderErrorBlock(ctx.state.error)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = document.getElementById('vault-passphrase') as HTMLInputElement;
|
||||
const btn = document.getElementById('vault-unlock-btn')!;
|
||||
|
||||
const doUnlock = async () => {
|
||||
const passphrase = input.value;
|
||||
if (!passphrase) return;
|
||||
btn.textContent = 'unlocking...';
|
||||
btn.setAttribute('disabled', 'true');
|
||||
const resp = await ctx.sendMessage({ type: 'unlock', passphrase });
|
||||
if (resp.ok) {
|
||||
ctx.state.unlocked = true;
|
||||
ctx.state.error = null;
|
||||
await ctx.loadManifest();
|
||||
render(ctx);
|
||||
} else {
|
||||
ctx.state.error = resp.error ?? 'unlock failed';
|
||||
render(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener('click', doUnlock);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doUnlock();
|
||||
});
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell (3-column: sidebar + list pane + drawer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderShell(ctx: VaultController, app: HTMLElement): void {
|
||||
if (!app.querySelector('.vault-shell')) {
|
||||
app.innerHTML = `
|
||||
<div class="vault-shell">
|
||||
${renderSidebarShell()}
|
||||
<div class="vault-list-pane" id="vault-list-pane"></div>
|
||||
<div class="vault-pane" id="vault-pane"></div>
|
||||
<div class="vault-drawer" id="vault-drawer"></div>
|
||||
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
|
||||
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
|
||||
</div>
|
||||
`;
|
||||
ctx.wireSidebar();
|
||||
wireTypePanel(ctx);
|
||||
}
|
||||
|
||||
applyShellViewClass(ctx);
|
||||
ctx.renderSidebarCategories();
|
||||
if (ctx.state.view === 'list') {
|
||||
ctx.renderListPane();
|
||||
if (ctx.state.drawerOpen && ctx.state.selectedItem) {
|
||||
ctx.renderDrawer(ctx.state.selectedItem);
|
||||
}
|
||||
} else {
|
||||
ctx.renderPane();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle which middle column is visible based on the current view.
|
||||
// list view → list-pane (+ optional drawer); other views → vault-pane.
|
||||
export function applyShellViewClass(ctx: VaultController): void {
|
||||
const shell = document.querySelector('.vault-shell');
|
||||
if (!shell) return;
|
||||
shell.classList.toggle('vault-shell--list', ctx.state.view === 'list');
|
||||
shell.classList.toggle('vault-shell--pane', ctx.state.view !== 'list');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right-side type picker panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function wireTypePanel(ctx: VaultController): void {
|
||||
document.getElementById('vault-type-scrim')?.addEventListener('click', () => closeTypePanel(ctx));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && ctx.state.typePanelOpen) closeTypePanel(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
export function openTypePanel(ctx: VaultController): void {
|
||||
const panel = document.getElementById('vault-type-panel');
|
||||
const scrim = document.getElementById('vault-type-scrim');
|
||||
if (!panel || !scrim) return;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="vault-type-panel__head">
|
||||
<div class="vault-type-panel__title">New item</div>
|
||||
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="vault-type-panel__hint">Choose a type</div>
|
||||
<div class="vault-type-list" role="menu">
|
||||
${PICKER_TYPES.map((t) => `
|
||||
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
|
||||
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
||||
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.classList.add('vault-type-panel--open');
|
||||
scrim.classList.add('vault-type-panel-scrim--visible');
|
||||
ctx.state.typePanelOpen = true;
|
||||
|
||||
panel.querySelector('#vault-type-close')?.addEventListener('click', () => closeTypePanel(ctx));
|
||||
|
||||
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type as ItemType;
|
||||
closeTypePanel(ctx);
|
||||
// Use the host's navigate hook so view + hash + visibility all update
|
||||
// together. This was the bug: bare setHash + renderPane left the
|
||||
// shell stuck in list view with #vault-pane hidden.
|
||||
ctx.state.newType = type;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.view = 'add';
|
||||
ctx.setHash('add', type);
|
||||
applyShellViewClass(ctx);
|
||||
ctx.renderSidebarCategories();
|
||||
ctx.renderPane();
|
||||
});
|
||||
});
|
||||
|
||||
// Focus first item for keyboard users
|
||||
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
export function closeTypePanel(ctx: VaultController): void {
|
||||
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
|
||||
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
|
||||
ctx.state.typePanelOpen = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color scheme + session-expired wiring (bootstrap helpers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function applyVaultColorScheme(): Promise<void> {
|
||||
await applyColorScheme();
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||
void applyColorScheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function wireSessionExpiredListener(ctx: VaultController): void {
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'session_expired') {
|
||||
ctx.state.unlocked = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.entries = [];
|
||||
ctx.state.error = null;
|
||||
render(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
190
extension/src/vault/vault-sidebar.ts
Normal file
190
extension/src/vault/vault-sidebar.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// Vault-tab sidebar column: its static markup, the category nav rendering,
|
||||
// nav-button wiring, and the (now debounced) search input. Each function
|
||||
// receives the VaultController (`ctx`) and reaches sibling concerns through it;
|
||||
// pure helpers come from vault-context. Imports only from shared/ and
|
||||
// vault-context, plus the leaf renderer vault-status — never from vault-shell
|
||||
// or vault.ts.
|
||||
|
||||
import type { ItemType } from '../shared/types';
|
||||
import {
|
||||
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, GLYPH_REFRESH,
|
||||
} from '../shared/glyphs';
|
||||
import { renderStatusIndicator, type VaultStatus } from './vault-status';
|
||||
import {
|
||||
type VaultController, typeIcon, typeLabel, getFilteredEntries,
|
||||
} from './vault-context';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 80;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar markup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderSidebarShell(): string {
|
||||
return `
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
</div>
|
||||
<div class="vault-sidebar__search">
|
||||
<input type="text" id="vault-search" placeholder="/ search…" />
|
||||
</div>
|
||||
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
||||
<div class="vault-sidebar__nav">
|
||||
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
||||
</div>
|
||||
<div class="vault-sidebar__footer">
|
||||
<div id="vault-status-slot"></div>
|
||||
<button class="vault-status-refresh" id="status-refresh-btn" type="button" title="Refresh status" aria-label="Refresh status">${GLYPH_REFRESH}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function wireSidebar(ctx: VaultController): void {
|
||||
// Search (debounced — trailing edge)
|
||||
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||
let searchTimer: number | undefined;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
if (searchTimer !== undefined) clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
ctx.state.searchQuery = searchInput.value;
|
||||
renderSidebarCategories(ctx);
|
||||
ctx.renderListPane();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
// Nav buttons
|
||||
document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const nav = (btn as HTMLElement).dataset.nav;
|
||||
if (nav === 'lock') {
|
||||
await ctx.sendMessage({ type: 'lock' });
|
||||
ctx.state.unlocked = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.entries = [];
|
||||
ctx.render();
|
||||
return;
|
||||
}
|
||||
if (nav === 'add') {
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.newType = null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.closeDrawer();
|
||||
ctx.openTypePanel();
|
||||
return;
|
||||
}
|
||||
if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') {
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.newType = null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.view = nav;
|
||||
ctx.setHash(nav);
|
||||
ctx.applyShellViewClass();
|
||||
ctx.renderPane();
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Global "/" shortcut to focus search; Esc to close drawer
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
searchInput?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && ctx.state.drawerOpen) {
|
||||
ctx.closeDrawer();
|
||||
ctx.renderListPane();
|
||||
}
|
||||
});
|
||||
|
||||
// Vault status indicator — refresh on mount + on the manual button only.
|
||||
// No timer polling: get_vault_status returns cached state and sync is
|
||||
// user-initiated (spec 2026-05-04, Phase 6).
|
||||
const refreshStatus = async (): Promise<void> => {
|
||||
const resp = await ctx.sendMessage({ type: 'get_vault_status' });
|
||||
if (!resp.ok) return;
|
||||
const slot = document.getElementById('vault-status-slot');
|
||||
if (slot) renderStatusIndicator(slot, resp.data as VaultStatus);
|
||||
};
|
||||
void refreshStatus();
|
||||
document.getElementById('status-refresh-btn')?.addEventListener('click', () => {
|
||||
void refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar category nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderSidebarCategories(ctx: VaultController): void {
|
||||
const container = document.getElementById('vault-categories');
|
||||
if (!container) return;
|
||||
|
||||
const filtered = getFilteredEntries(ctx.state);
|
||||
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
||||
|
||||
const allCount = filtered.length;
|
||||
const isAllActive = !ctx.state.activeGroup && ctx.state.view === 'list';
|
||||
|
||||
let html = `
|
||||
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
|
||||
<span class="vault-category-row__icon">◈</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
for (const t of typeOrder) {
|
||||
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||
// Always show Login (staple type); hide other types when empty.
|
||||
if (count === 0 && t !== 'login') continue;
|
||||
const isActive = ctx.state.activeGroup === t;
|
||||
html += `
|
||||
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
ctx.state.activeGroup = btn.dataset.group || null;
|
||||
ctx.state.drawerOpen = false;
|
||||
ctx.state.selectedId = null;
|
||||
ctx.state.selectedItem = null;
|
||||
ctx.state.view = 'list';
|
||||
ctx.setHash('list');
|
||||
ctx.applyShellViewClass();
|
||||
renderSidebarCategories(ctx);
|
||||
ctx.renderListPane();
|
||||
ctx.closeDrawer();
|
||||
});
|
||||
});
|
||||
}
|
||||
33
extension/src/vault/vault-status.ts
Normal file
33
extension/src/vault/vault-status.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
GLYPH_SYNCED,
|
||||
GLYPH_AHEAD,
|
||||
GLYPH_BEHIND,
|
||||
GLYPH_PENDING,
|
||||
} from '../shared/glyphs';
|
||||
import { relativeTime } from '../shared/relative-time';
|
||||
import type { GetVaultStatusResponse } from '../shared/messages';
|
||||
|
||||
// The indicator consumes exactly the get_vault_status response payload; alias
|
||||
// it (rather than re-declaring the four fields) so the shape stays single-
|
||||
// sourced and can't drift from the SW handler. lastSyncAt is a unix timestamp
|
||||
// in SECONDS, or null when the vault has never synced.
|
||||
export type VaultStatus = GetVaultStatusResponse['data'];
|
||||
|
||||
export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void {
|
||||
const ts = status.lastSyncAt !== null
|
||||
? `last sync ${relativeTime(status.lastSyncAt)}`
|
||||
: 'never synced';
|
||||
|
||||
const parts: string[] = [];
|
||||
if (status.pendingItems > 0) parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`);
|
||||
if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`);
|
||||
if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`);
|
||||
if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="vault-status">
|
||||
<div class="vault-status__state">${parts.join(' · ')}</div>
|
||||
<div class="vault-status__ts">${ts}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2113,3 +2113,39 @@ textarea {
|
||||
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
|
||||
.history-index-row__title { color: var(--text); }
|
||||
.history-index-row__meta { font-size: 11px; }
|
||||
|
||||
/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts +
|
||||
vault-sidebar.ts). Indicator renders into #vault-status-slot; the ↻ button
|
||||
triggers a manual refresh (no timer polling). */
|
||||
.vault-sidebar__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
#vault-status-slot { flex: 1; min-width: 0; }
|
||||
.vault-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.vault-status__state { color: var(--text-dim); }
|
||||
.vault-status__ts { color: var(--text-muted); }
|
||||
.vault-status-refresh {
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vault-status-refresh:hover { color: var(--text); background: var(--bg-input); }
|
||||
.vault-status-refresh:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,10 @@ describe("RelayQueue", () => {
|
||||
assert.ok(isRole("dev-a"));
|
||||
assert.ok(isRole("dev-b"));
|
||||
assert.ok(isRole("dev-c"));
|
||||
assert.ok(!isRole("dev-d"));
|
||||
assert.ok(isRole("dev-d"));
|
||||
assert.ok(isRole("dev-e"));
|
||||
assert.ok(isRole("dev-f"));
|
||||
assert.ok(!isRole("dev-g"));
|
||||
assert.ok(!isRole(""));
|
||||
assert.ok(!isRole("PM"));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c";
|
||||
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c" | "dev-d" | "dev-e" | "dev-f";
|
||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||
|
||||
export interface RelayMessage {
|
||||
@@ -12,7 +12,7 @@ export interface RelayMessage {
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c"]);
|
||||
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"]);
|
||||
|
||||
export function isRole(s: string): s is Role {
|
||||
return KNOWN_ROLES.has(s);
|
||||
@@ -24,6 +24,9 @@ export class RelayQueue {
|
||||
["dev-a", []],
|
||||
["dev-b", []],
|
||||
["dev-c", []],
|
||||
["dev-d", []],
|
||||
["dev-e", []],
|
||||
["dev-f", []],
|
||||
]);
|
||||
|
||||
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||
|
||||
@@ -20,12 +20,12 @@ const TOOLS = [
|
||||
properties: {
|
||||
from: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||
description: "Your role name",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||
description: "Recipient role name",
|
||||
},
|
||||
kind: {
|
||||
@@ -50,7 +50,7 @@ const TOOLS = [
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
@@ -66,7 +66,7 @@ const TOOLS = [
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user