jettypod 4.4.118 → 4.4.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//! Build script: parses V1__baseline.sql to extract table names,
|
|
2
|
+
//! work_items column names, and full column definitions for schema fixup.
|
|
3
|
+
|
|
4
|
+
use std::env;
|
|
5
|
+
use std::fs;
|
|
6
|
+
use std::path::Path;
|
|
7
|
+
|
|
8
|
+
fn main() {
|
|
9
|
+
println!("cargo:rerun-if-changed=migrations/V1__baseline.sql");
|
|
10
|
+
|
|
11
|
+
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
|
12
|
+
let sql_path = Path::new(&manifest_dir)
|
|
13
|
+
.join("migrations")
|
|
14
|
+
.join("V1__baseline.sql");
|
|
15
|
+
|
|
16
|
+
let sql = fs::read_to_string(&sql_path).expect("Failed to read V1__baseline.sql");
|
|
17
|
+
|
|
18
|
+
let mut tables: Vec<String> = Vec::new();
|
|
19
|
+
let mut work_item_columns: Vec<String> = Vec::new();
|
|
20
|
+
// (column_name, full_definition) for ALTER TABLE ADD COLUMN fixup
|
|
21
|
+
let mut work_item_col_defs: Vec<(String, String)> = Vec::new();
|
|
22
|
+
let mut in_work_items = false;
|
|
23
|
+
|
|
24
|
+
for line in sql.lines() {
|
|
25
|
+
let trimmed = line.trim();
|
|
26
|
+
|
|
27
|
+
// Detect CREATE TABLE statements
|
|
28
|
+
if let Some(rest) = trimmed.strip_prefix("CREATE TABLE IF NOT EXISTS ") {
|
|
29
|
+
let table_name = rest
|
|
30
|
+
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
|
31
|
+
.next()
|
|
32
|
+
.unwrap_or("");
|
|
33
|
+
if !table_name.is_empty() {
|
|
34
|
+
tables.push(table_name.to_string());
|
|
35
|
+
in_work_items = table_name == "work_items";
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Inside work_items, extract column names and definitions
|
|
41
|
+
if in_work_items {
|
|
42
|
+
if trimmed.starts_with(");") {
|
|
43
|
+
in_work_items = false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if trimmed.is_empty()
|
|
47
|
+
|| trimmed.starts_with("--")
|
|
48
|
+
|| trimmed.starts_with("FOREIGN")
|
|
49
|
+
|| trimmed.starts_with("UNIQUE")
|
|
50
|
+
|| trimmed.starts_with("CHECK")
|
|
51
|
+
{
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Extract column name (first word) and full definition (rest)
|
|
55
|
+
let clean = trimmed.trim_end_matches(',');
|
|
56
|
+
let col_name = clean.split_whitespace().next().unwrap_or("");
|
|
57
|
+
if !col_name.is_empty() {
|
|
58
|
+
work_item_columns.push(col_name.to_string());
|
|
59
|
+
// Full definition is everything after the column name
|
|
60
|
+
let def = clean[col_name.len()..].trim().to_string();
|
|
61
|
+
work_item_col_defs.push((col_name.to_string(), def));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let out_dir = env::var("OUT_DIR").unwrap();
|
|
67
|
+
let dest_path = Path::new(&out_dir).join("schema_info.rs");
|
|
68
|
+
|
|
69
|
+
let columns_str = work_item_columns
|
|
70
|
+
.iter()
|
|
71
|
+
.map(|c| format!(" \"{c}\""))
|
|
72
|
+
.collect::<Vec<_>>()
|
|
73
|
+
.join(",\n");
|
|
74
|
+
|
|
75
|
+
let tables_str = tables
|
|
76
|
+
.iter()
|
|
77
|
+
.map(|t| format!(" \"{t}\""))
|
|
78
|
+
.collect::<Vec<_>>()
|
|
79
|
+
.join(",\n");
|
|
80
|
+
|
|
81
|
+
let col_defs_str = work_item_col_defs
|
|
82
|
+
.iter()
|
|
83
|
+
.map(|(name, def)| format!(" (\"{name}\", \"{def}\")"))
|
|
84
|
+
.collect::<Vec<_>>()
|
|
85
|
+
.join(",\n");
|
|
86
|
+
|
|
87
|
+
let generated = format!(
|
|
88
|
+
"/// Work-items columns derived from V1__baseline.sql at build time.\n\
|
|
89
|
+
const EXPECTED_WORK_ITEM_COLUMNS: &[&str] = &[\n{columns_str}\n];\n\n\
|
|
90
|
+
/// Table names derived from V1__baseline.sql at build time.\n\
|
|
91
|
+
const EXPECTED_TABLES: &[&str] = &[\n{tables_str}\n];\n\n\
|
|
92
|
+
/// Column definitions for ALTER TABLE fixup of missing columns.\n\
|
|
93
|
+
/// Each tuple is (column_name, type_and_default_clause).\n\
|
|
94
|
+
const WORK_ITEM_COLUMN_DEFS: &[(&str, &str)] = &[\n{col_defs_str}\n];\n"
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
fs::write(dest_path, generated).expect("Failed to write schema_info.rs");
|
|
98
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
-- Baseline schema: captures the cumulative effect of Node.js migrations 001-030.
|
|
2
|
+
-- Uses CREATE TABLE/INDEX IF NOT EXISTS so it's idempotent on existing databases.
|
|
3
|
+
-- Future schema changes should be added as V2, V3, etc.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS work_items (
|
|
6
|
+
id INTEGER PRIMARY KEY,
|
|
7
|
+
type TEXT NOT NULL,
|
|
8
|
+
title TEXT NOT NULL,
|
|
9
|
+
description TEXT,
|
|
10
|
+
status TEXT DEFAULT 'backlog',
|
|
11
|
+
parent_id INTEGER,
|
|
12
|
+
epic_id INTEGER,
|
|
13
|
+
branch_name TEXT,
|
|
14
|
+
file_paths TEXT,
|
|
15
|
+
commit_sha TEXT,
|
|
16
|
+
mode TEXT,
|
|
17
|
+
current INTEGER DEFAULT 0,
|
|
18
|
+
phase TEXT,
|
|
19
|
+
prototype_files TEXT,
|
|
20
|
+
discovery_winner TEXT,
|
|
21
|
+
discovery_rationale TEXT,
|
|
22
|
+
scenario_file TEXT,
|
|
23
|
+
completed_at TEXT,
|
|
24
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
25
|
+
needs_discovery INTEGER DEFAULT 0,
|
|
26
|
+
worktree_path TEXT,
|
|
27
|
+
display_order INTEGER,
|
|
28
|
+
architectural_decision TEXT,
|
|
29
|
+
discovery_completed_at TEXT,
|
|
30
|
+
rejection_reason TEXT,
|
|
31
|
+
rejected_at TEXT,
|
|
32
|
+
plan_at_creation TEXT DEFAULT NULL,
|
|
33
|
+
ready_for_review INTEGER DEFAULT 0,
|
|
34
|
+
conversational INTEGER DEFAULT 0,
|
|
35
|
+
rejection_count INTEGER DEFAULT 0,
|
|
36
|
+
rejection_round INTEGER,
|
|
37
|
+
rejection_history TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS project_config (
|
|
41
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
42
|
+
project_state TEXT DEFAULT 'internal',
|
|
43
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS external_readiness_checklist (
|
|
47
|
+
id INTEGER PRIMARY KEY,
|
|
48
|
+
category TEXT NOT NULL,
|
|
49
|
+
item_key TEXT NOT NULL,
|
|
50
|
+
title TEXT NOT NULL,
|
|
51
|
+
description TEXT,
|
|
52
|
+
completed INTEGER DEFAULT 0,
|
|
53
|
+
completed_at DATETIME,
|
|
54
|
+
UNIQUE(category, item_key)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS discovery_decisions (
|
|
58
|
+
id INTEGER PRIMARY KEY,
|
|
59
|
+
work_item_id INTEGER NOT NULL,
|
|
60
|
+
aspect TEXT NOT NULL,
|
|
61
|
+
decision TEXT NOT NULL,
|
|
62
|
+
rationale TEXT NOT NULL,
|
|
63
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
64
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS skill_executions (
|
|
68
|
+
id INTEGER PRIMARY KEY,
|
|
69
|
+
work_item_id INTEGER NOT NULL,
|
|
70
|
+
skill_name TEXT NOT NULL,
|
|
71
|
+
status TEXT DEFAULT 'in_progress',
|
|
72
|
+
step_reached INTEGER DEFAULT 1,
|
|
73
|
+
context_json TEXT,
|
|
74
|
+
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
75
|
+
completed_at DATETIME,
|
|
76
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS workflow_gates (
|
|
80
|
+
id INTEGER PRIMARY KEY,
|
|
81
|
+
work_item_id INTEGER NOT NULL,
|
|
82
|
+
gate_name TEXT NOT NULL,
|
|
83
|
+
passed_at DATETIME,
|
|
84
|
+
UNIQUE(work_item_id, gate_name),
|
|
85
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
89
|
+
key TEXT PRIMARY KEY,
|
|
90
|
+
value TEXT
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS worktrees (
|
|
99
|
+
id INTEGER PRIMARY KEY,
|
|
100
|
+
work_item_id INTEGER NOT NULL,
|
|
101
|
+
branch_name TEXT NOT NULL,
|
|
102
|
+
worktree_path TEXT NOT NULL,
|
|
103
|
+
status TEXT NOT NULL CHECK(status IN ('active', 'merging', 'merged', 'cleanup_pending', 'cleaned')),
|
|
104
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
105
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id)
|
|
107
|
+
);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_worktrees_work_item_id ON worktrees(work_item_id);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_worktrees_status ON worktrees(status);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS merge_locks (
|
|
112
|
+
id INTEGER PRIMARY KEY,
|
|
113
|
+
locked_by TEXT NOT NULL,
|
|
114
|
+
locked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
115
|
+
operation TEXT NOT NULL DEFAULT 'merging',
|
|
116
|
+
work_item_id INTEGER NOT NULL,
|
|
117
|
+
heartbeat_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_merge_locks_locked_at ON merge_locks(locked_at);
|
|
120
|
+
|
|
121
|
+
CREATE TABLE IF NOT EXISTS workflow_checkpoints (
|
|
122
|
+
id INTEGER PRIMARY KEY,
|
|
123
|
+
skill_name TEXT NOT NULL,
|
|
124
|
+
current_step INTEGER NOT NULL,
|
|
125
|
+
total_steps INTEGER,
|
|
126
|
+
context_json TEXT,
|
|
127
|
+
branch_name TEXT NOT NULL,
|
|
128
|
+
work_item_id INTEGER,
|
|
129
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
130
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
131
|
+
);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_checkpoints_branch ON workflow_checkpoints(branch_name);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS claude_sessions (
|
|
135
|
+
id INTEGER PRIMARY KEY,
|
|
136
|
+
work_item_id INTEGER UNIQUE,
|
|
137
|
+
title TEXT NOT NULL,
|
|
138
|
+
session_title TEXT,
|
|
139
|
+
status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'error', 'orphaned')),
|
|
140
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
141
|
+
completed_at TEXT,
|
|
142
|
+
content TEXT,
|
|
143
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id) ON DELETE SET NULL
|
|
144
|
+
);
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_claude_sessions_status ON claude_sessions(status);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_claude_sessions_work_item ON claude_sessions(work_item_id);
|
|
147
|
+
|
|
148
|
+
CREATE TABLE IF NOT EXISTS env_vars (
|
|
149
|
+
id INTEGER PRIMARY KEY,
|
|
150
|
+
name TEXT NOT NULL UNIQUE,
|
|
151
|
+
value TEXT NOT NULL,
|
|
152
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
153
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
CREATE TABLE IF NOT EXISTS test_runs (
|
|
157
|
+
id INTEGER PRIMARY KEY,
|
|
158
|
+
run_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
159
|
+
total_scenarios INTEGER NOT NULL,
|
|
160
|
+
passed INTEGER NOT NULL,
|
|
161
|
+
failed INTEGER NOT NULL,
|
|
162
|
+
pending INTEGER NOT NULL,
|
|
163
|
+
duration_ms INTEGER NOT NULL
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
CREATE TABLE IF NOT EXISTS test_scenarios (
|
|
167
|
+
id INTEGER PRIMARY KEY,
|
|
168
|
+
feature_file TEXT NOT NULL,
|
|
169
|
+
scenario_name TEXT NOT NULL,
|
|
170
|
+
UNIQUE(feature_file, scenario_name)
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS test_results (
|
|
174
|
+
id INTEGER PRIMARY KEY,
|
|
175
|
+
test_run_id INTEGER NOT NULL,
|
|
176
|
+
scenario_id INTEGER NOT NULL,
|
|
177
|
+
status TEXT NOT NULL CHECK(status IN ('passed', 'failed', 'pending')),
|
|
178
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
179
|
+
error_message TEXT,
|
|
180
|
+
failed_step TEXT,
|
|
181
|
+
run_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
182
|
+
FOREIGN KEY (test_run_id) REFERENCES test_runs(id),
|
|
183
|
+
FOREIGN KEY (scenario_id) REFERENCES test_scenarios(id)
|
|
184
|
+
);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_test_results_run ON test_results(test_run_id);
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_test_results_scenario ON test_results(scenario_id);
|
|
187
|
+
|
|
188
|
+
CREATE TABLE IF NOT EXISTS worktree_sessions (
|
|
189
|
+
id INTEGER PRIMARY KEY,
|
|
190
|
+
worktree_path TEXT UNIQUE NOT NULL,
|
|
191
|
+
work_item_id INTEGER NOT NULL,
|
|
192
|
+
branch_name TEXT NOT NULL,
|
|
193
|
+
last_activity DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
194
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id)
|
|
195
|
+
);
|
|
196
|
+
CREATE INDEX IF NOT EXISTS idx_worktree_sessions_path ON worktree_sessions(worktree_path);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_worktree_sessions_work_item ON worktree_sessions(work_item_id);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Add indexes on work_items for common query patterns.
|
|
2
|
+
-- status: used by get_tree() WHERE clause and kanban grouping.
|
|
3
|
+
-- parent_id: used by get_children(), find_epic() parent chain walks, and grouping.
|
|
4
|
+
|
|
5
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(status);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_parent_id ON work_items(parent_id);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
//! Auth token management.
|
|
2
|
+
//!
|
|
3
|
+
//! Reads `~/Library/Application Support/jettypod/auth.json` to get the
|
|
4
|
+
//! current user's login state and plan info.
|
|
5
|
+
//!
|
|
6
|
+
//! The auth.json file is written by the Electron/Tauri desktop app after
|
|
7
|
+
//! completing the Google OAuth flow. The format is:
|
|
8
|
+
//! ```json
|
|
9
|
+
//! {
|
|
10
|
+
//! "token": "jwt-string",
|
|
11
|
+
//! "user": { "id": "...", "email": "...", "plan": "free" },
|
|
12
|
+
//! "savedAt": "2024-01-15T12:00:00.000Z"
|
|
13
|
+
//! }
|
|
14
|
+
//! ```
|
|
15
|
+
|
|
16
|
+
use crate::error::Result;
|
|
17
|
+
use serde::{Deserialize, Serialize};
|
|
18
|
+
use std::path::PathBuf;
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/// Contents of auth.json.
|
|
25
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
26
|
+
pub struct AuthData {
|
|
27
|
+
pub token: String,
|
|
28
|
+
|
|
29
|
+
#[serde(default)]
|
|
30
|
+
pub user: Option<AuthUser>,
|
|
31
|
+
|
|
32
|
+
#[serde(rename = "savedAt", default)]
|
|
33
|
+
pub saved_at: Option<String>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// User info decoded from the JWT and stored alongside the token.
|
|
37
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
38
|
+
pub struct AuthUser {
|
|
39
|
+
#[serde(default)]
|
|
40
|
+
pub id: Option<String>,
|
|
41
|
+
|
|
42
|
+
#[serde(default)]
|
|
43
|
+
pub email: Option<String>,
|
|
44
|
+
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
pub name: Option<String>,
|
|
47
|
+
|
|
48
|
+
#[serde(default)]
|
|
49
|
+
pub plan: Option<String>,
|
|
50
|
+
|
|
51
|
+
/// Preserve unknown fields.
|
|
52
|
+
#[serde(flatten)]
|
|
53
|
+
pub extra: serde_json::Map<String, serde_json::Value>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// JWT payload — the decoded middle section of the token.
|
|
57
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
58
|
+
pub struct JwtPayload {
|
|
59
|
+
/// Subject (user ID).
|
|
60
|
+
pub sub: Option<String>,
|
|
61
|
+
|
|
62
|
+
pub email: Option<String>,
|
|
63
|
+
|
|
64
|
+
pub plan: Option<String>,
|
|
65
|
+
|
|
66
|
+
/// Issued-at timestamp (Unix seconds).
|
|
67
|
+
pub iat: Option<i64>,
|
|
68
|
+
|
|
69
|
+
/// Expiration timestamp (Unix seconds).
|
|
70
|
+
pub exp: Option<i64>,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Path resolution
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/// Get the path to auth.json.
|
|
78
|
+
///
|
|
79
|
+
/// On macOS: `~/Library/Application Support/jettypod/auth.json`
|
|
80
|
+
/// Falls back to `~/.config/jettypod/auth.json` on other platforms.
|
|
81
|
+
pub fn auth_path() -> Option<PathBuf> {
|
|
82
|
+
#[cfg(target_os = "macos")]
|
|
83
|
+
{
|
|
84
|
+
dirs::home_dir().map(|h| {
|
|
85
|
+
h.join("Library")
|
|
86
|
+
.join("Application Support")
|
|
87
|
+
.join("jettypod")
|
|
88
|
+
.join("auth.json")
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#[cfg(not(target_os = "macos"))]
|
|
93
|
+
{
|
|
94
|
+
dirs::config_dir().map(|c| c.join("jettypod").join("auth.json"))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Operations
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/// Read auth.json and return the parsed data.
|
|
103
|
+
///
|
|
104
|
+
/// Returns `None` if the file doesn't exist or can't be parsed.
|
|
105
|
+
pub fn read() -> Option<AuthData> {
|
|
106
|
+
let path = auth_path()?;
|
|
107
|
+
let contents = std::fs::read_to_string(&path).ok()?;
|
|
108
|
+
serde_json::from_str(&contents).ok()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Check whether the user is logged in (auth.json exists with a token).
|
|
112
|
+
pub fn is_logged_in() -> bool {
|
|
113
|
+
read().is_some()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Get the current user's plan, or `"free"` if not logged in.
|
|
117
|
+
pub fn current_plan() -> String {
|
|
118
|
+
read()
|
|
119
|
+
.and_then(|d| d.user)
|
|
120
|
+
.and_then(|u| u.plan)
|
|
121
|
+
.unwrap_or_else(|| "free".into())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Save auth data to auth.json.
|
|
125
|
+
pub fn write(data: &AuthData) -> Result<()> {
|
|
126
|
+
let path = auth_path().ok_or_else(|| {
|
|
127
|
+
crate::error::CoreError::Config("Cannot determine auth.json path".into())
|
|
128
|
+
})?;
|
|
129
|
+
|
|
130
|
+
if let Some(parent) = path.parent() {
|
|
131
|
+
std::fs::create_dir_all(parent)?;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let json = serde_json::to_string_pretty(data)?;
|
|
135
|
+
std::fs::write(&path, json)?;
|
|
136
|
+
Ok(())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Delete auth.json (logout).
|
|
140
|
+
pub fn logout() -> Result<()> {
|
|
141
|
+
if let Some(path) = auth_path() {
|
|
142
|
+
if path.exists() {
|
|
143
|
+
std::fs::remove_file(&path)?;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
Ok(())
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Decode the payload section of a JWT without verification.
|
|
150
|
+
///
|
|
151
|
+
/// This is a quick decode for extracting user info — it does NOT
|
|
152
|
+
/// validate the signature. For full verification, use the update
|
|
153
|
+
/// server's auth endpoint.
|
|
154
|
+
pub fn decode_jwt_payload(token: &str) -> Option<JwtPayload> {
|
|
155
|
+
let parts: Vec<&str> = token.split('.').collect();
|
|
156
|
+
if parts.len() != 3 {
|
|
157
|
+
return None;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Decode base64url payload (second part)
|
|
161
|
+
use base64_decode::decode_base64url;
|
|
162
|
+
let payload_bytes = decode_base64url(parts[1])?;
|
|
163
|
+
serde_json::from_slice(&payload_bytes).ok()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Inline base64url decoder to avoid adding a dependency just for this.
|
|
167
|
+
mod base64_decode {
|
|
168
|
+
pub fn decode_base64url(input: &str) -> Option<Vec<u8>> {
|
|
169
|
+
// Pad to multiple of 4
|
|
170
|
+
let padded = match input.len() % 4 {
|
|
171
|
+
2 => format!("{}==", input),
|
|
172
|
+
3 => format!("{}=", input),
|
|
173
|
+
0 => input.to_string(),
|
|
174
|
+
_ => return None,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Replace URL-safe characters with standard base64
|
|
178
|
+
let standard = padded.replace('-', "+").replace('_', "/");
|
|
179
|
+
|
|
180
|
+
// Use a minimal base64 decode
|
|
181
|
+
base64_standard_decode(&standard)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fn base64_standard_decode(input: &str) -> Option<Vec<u8>> {
|
|
185
|
+
const TABLE: &[u8; 64] =
|
|
186
|
+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
187
|
+
|
|
188
|
+
fn decode_char(c: u8) -> Option<u8> {
|
|
189
|
+
TABLE.iter().position(|&b| b == c).map(|p| p as u8)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let bytes = input.as_bytes();
|
|
193
|
+
if bytes.len() % 4 != 0 {
|
|
194
|
+
return None;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let mut output = Vec::with_capacity(bytes.len() * 3 / 4);
|
|
198
|
+
|
|
199
|
+
for chunk in bytes.chunks(4) {
|
|
200
|
+
let mut buf = [0u8; 4];
|
|
201
|
+
let mut pad = 0;
|
|
202
|
+
|
|
203
|
+
for (i, &b) in chunk.iter().enumerate() {
|
|
204
|
+
if b == b'=' {
|
|
205
|
+
pad += 1;
|
|
206
|
+
buf[i] = 0;
|
|
207
|
+
} else {
|
|
208
|
+
buf[i] = decode_char(b)?;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
output.push((buf[0] << 2) | (buf[1] >> 4));
|
|
213
|
+
if pad < 2 {
|
|
214
|
+
output.push((buf[1] << 4) | (buf[2] >> 2));
|
|
215
|
+
}
|
|
216
|
+
if pad < 1 {
|
|
217
|
+
output.push((buf[2] << 6) | buf[3]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
Some(output)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Tests
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
#[cfg(test)]
|
|
230
|
+
mod tests {
|
|
231
|
+
use super::*;
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn decode_jwt_payload_works() {
|
|
235
|
+
// Build a minimal JWT: header.payload.signature
|
|
236
|
+
// Payload: {"sub":"user123","email":"test@example.com","plan":"free","iat":1700000000,"exp":1702592000}
|
|
237
|
+
let payload_json = r#"{"sub":"user123","email":"test@example.com","plan":"free","iat":1700000000,"exp":1702592000}"#;
|
|
238
|
+
let payload_b64 = base64url_encode(payload_json.as_bytes());
|
|
239
|
+
let header_b64 = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
|
|
240
|
+
let token = format!("{}.{}.fake_signature", header_b64, payload_b64);
|
|
241
|
+
|
|
242
|
+
let payload = decode_jwt_payload(&token).unwrap();
|
|
243
|
+
assert_eq!(payload.sub.as_deref(), Some("user123"));
|
|
244
|
+
assert_eq!(payload.email.as_deref(), Some("test@example.com"));
|
|
245
|
+
assert_eq!(payload.plan.as_deref(), Some("free"));
|
|
246
|
+
assert_eq!(payload.iat, Some(1700000000));
|
|
247
|
+
assert_eq!(payload.exp, Some(1702592000));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#[test]
|
|
251
|
+
fn decode_jwt_payload_rejects_invalid() {
|
|
252
|
+
assert!(decode_jwt_payload("not-a-jwt").is_none());
|
|
253
|
+
assert!(decode_jwt_payload("a.b").is_none());
|
|
254
|
+
assert!(decode_jwt_payload("").is_none());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn auth_path_is_set() {
|
|
259
|
+
let path = auth_path();
|
|
260
|
+
assert!(path.is_some());
|
|
261
|
+
let p = path.unwrap();
|
|
262
|
+
assert!(p.ends_with("auth.json"));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[test]
|
|
266
|
+
fn current_plan_defaults_to_free() {
|
|
267
|
+
// When no auth file exists (test env), should return "free"
|
|
268
|
+
assert_eq!(current_plan(), "free");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// Helper: base64url encode (no padding).
|
|
272
|
+
fn base64url_encode(input: &[u8]) -> String {
|
|
273
|
+
const TABLE: &[u8; 64] =
|
|
274
|
+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
275
|
+
|
|
276
|
+
let mut output = String::new();
|
|
277
|
+
for chunk in input.chunks(3) {
|
|
278
|
+
let b0 = chunk[0] as u32;
|
|
279
|
+
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
|
280
|
+
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
|
281
|
+
let triple = (b0 << 16) | (b1 << 8) | b2;
|
|
282
|
+
|
|
283
|
+
output.push(TABLE[((triple >> 18) & 0x3F) as usize] as char);
|
|
284
|
+
output.push(TABLE[((triple >> 12) & 0x3F) as usize] as char);
|
|
285
|
+
if chunk.len() > 1 {
|
|
286
|
+
output.push(TABLE[((triple >> 6) & 0x3F) as usize] as char);
|
|
287
|
+
}
|
|
288
|
+
if chunk.len() > 2 {
|
|
289
|
+
output.push(TABLE[(triple & 0x3F) as usize] as char);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
output
|
|
293
|
+
}
|
|
294
|
+
}
|