@getjack/jack 0.1.2 → 0.1.4

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.
Files changed (91) hide show
  1. package/README.md +77 -29
  2. package/package.json +54 -47
  3. package/src/commands/agents.ts +145 -10
  4. package/src/commands/down.ts +110 -102
  5. package/src/commands/feedback.ts +189 -0
  6. package/src/commands/init.ts +8 -12
  7. package/src/commands/login.ts +88 -0
  8. package/src/commands/logout.ts +14 -0
  9. package/src/commands/logs.ts +21 -0
  10. package/src/commands/mcp.ts +134 -7
  11. package/src/commands/new.ts +43 -17
  12. package/src/commands/open.ts +13 -6
  13. package/src/commands/projects.ts +269 -143
  14. package/src/commands/secrets.ts +413 -0
  15. package/src/commands/services.ts +96 -123
  16. package/src/commands/ship.ts +5 -1
  17. package/src/commands/whoami.ts +31 -0
  18. package/src/index.ts +218 -144
  19. package/src/lib/agent-files.ts +34 -0
  20. package/src/lib/agents.ts +390 -22
  21. package/src/lib/asset-hash.ts +50 -0
  22. package/src/lib/auth/client.ts +115 -0
  23. package/src/lib/auth/constants.ts +5 -0
  24. package/src/lib/auth/guard.ts +57 -0
  25. package/src/lib/auth/index.ts +18 -0
  26. package/src/lib/auth/store.ts +54 -0
  27. package/src/lib/binding-validator.ts +136 -0
  28. package/src/lib/build-helper.ts +211 -0
  29. package/src/lib/cloudflare-api.ts +24 -0
  30. package/src/lib/config.ts +5 -6
  31. package/src/lib/control-plane.ts +295 -0
  32. package/src/lib/debug.ts +3 -1
  33. package/src/lib/deploy-mode.ts +93 -0
  34. package/src/lib/deploy-upload.ts +92 -0
  35. package/src/lib/errors.ts +2 -0
  36. package/src/lib/github.ts +31 -1
  37. package/src/lib/hooks.ts +4 -12
  38. package/src/lib/intent.ts +88 -0
  39. package/src/lib/jsonc.ts +125 -0
  40. package/src/lib/local-paths.test.ts +902 -0
  41. package/src/lib/local-paths.ts +258 -0
  42. package/src/lib/managed-deploy.ts +175 -0
  43. package/src/lib/managed-down.ts +159 -0
  44. package/src/lib/mcp-config.ts +55 -34
  45. package/src/lib/names.ts +9 -29
  46. package/src/lib/project-operations.ts +676 -249
  47. package/src/lib/project-resolver.ts +476 -0
  48. package/src/lib/registry.ts +76 -37
  49. package/src/lib/resources.ts +196 -0
  50. package/src/lib/schema.ts +30 -1
  51. package/src/lib/storage/file-filter.ts +1 -0
  52. package/src/lib/storage/index.ts +5 -1
  53. package/src/lib/telemetry.ts +14 -0
  54. package/src/lib/tty.ts +15 -0
  55. package/src/lib/zip-packager.ts +255 -0
  56. package/src/mcp/resources/index.ts +8 -2
  57. package/src/mcp/server.ts +32 -4
  58. package/src/mcp/tools/index.ts +35 -13
  59. package/src/mcp/types.ts +6 -0
  60. package/src/mcp/utils.ts +1 -1
  61. package/src/templates/index.ts +42 -4
  62. package/src/templates/types.ts +13 -0
  63. package/templates/CLAUDE.md +166 -0
  64. package/templates/api/.jack.json +4 -0
  65. package/templates/api/bun.lock +1 -0
  66. package/templates/api/wrangler.jsonc +5 -0
  67. package/templates/hello/.jack.json +28 -0
  68. package/templates/hello/package.json +10 -0
  69. package/templates/hello/src/index.ts +11 -0
  70. package/templates/hello/tsconfig.json +11 -0
  71. package/templates/hello/wrangler.jsonc +5 -0
  72. package/templates/miniapp/.jack.json +15 -4
  73. package/templates/miniapp/bun.lock +135 -40
  74. package/templates/miniapp/index.html +1 -0
  75. package/templates/miniapp/package.json +3 -1
  76. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  77. package/templates/miniapp/public/icon.png +0 -0
  78. package/templates/miniapp/public/og.png +0 -0
  79. package/templates/miniapp/schema.sql +8 -0
  80. package/templates/miniapp/src/App.tsx +254 -3
  81. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  82. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  83. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  84. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  85. package/templates/miniapp/src/index.css +15 -0
  86. package/templates/miniapp/src/lib/api.ts +2 -1
  87. package/templates/miniapp/src/worker.ts +515 -1
  88. package/templates/miniapp/wrangler.jsonc +15 -3
  89. package/LICENSE +0 -190
  90. package/src/commands/cloud.ts +0 -230
  91. package/templates/api/wrangler.toml +0 -3
package/src/lib/agents.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { existsSync } from "node:fs";
2
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { delimiter, extname, join } from "node:path";
5
5
  import { type AgentConfig, type AgentLaunchConfig, readConfig, writeConfig } from "./config.ts";
6
+ import { debug, isDebug } from "./debug.ts";
7
+ import { restoreTty } from "./tty";
6
8
 
7
9
  // Re-export AgentConfig for consumers
8
10
  export type { AgentConfig } from "./config.ts";
@@ -39,6 +41,12 @@ export interface DetectionResult {
39
41
  total: number;
40
42
  }
41
43
 
44
+ export interface OneShotReporter {
45
+ info(message: string): void;
46
+ warn(message: string): void;
47
+ status?(message: string): void;
48
+ }
49
+
42
50
  /**
43
51
  * Result of validating agent paths
44
52
  */
@@ -113,9 +121,7 @@ function findExecutable(command: string): string | null {
113
121
  if (process.platform === "win32") {
114
122
  const extension = extname(command);
115
123
  const extensions =
116
- extension.length > 0
117
- ? [""]
118
- : (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";");
124
+ extension.length > 0 ? [""] : (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";");
119
125
 
120
126
  for (const basePath of paths) {
121
127
  for (const ext of extensions) {
@@ -374,14 +380,11 @@ export async function getAgentLaunch(id: string): Promise<AgentLaunchConfig | nu
374
380
  return resolved ?? null;
375
381
  }
376
382
 
377
- export async function getPreferredLaunchAgent(): Promise<
378
- | {
379
- id: string;
380
- definition: AgentDefinition;
381
- launch: AgentLaunchConfig;
382
- }
383
- | null
384
- > {
383
+ export async function getPreferredLaunchAgent(): Promise<{
384
+ id: string;
385
+ definition: AgentDefinition;
386
+ launch: AgentLaunchConfig;
387
+ } | null> {
385
388
  const config = await readConfig();
386
389
  if (!config?.agents) return null;
387
390
 
@@ -417,22 +420,48 @@ export async function getPreferredLaunchAgent(): Promise<
417
420
  return null;
418
421
  }
419
422
 
423
+ /**
424
+ * Context to pass to the agent on launch
425
+ */
426
+ export interface AgentLaunchContext {
427
+ projectName?: string;
428
+ url?: string | null;
429
+ }
430
+
431
+ /**
432
+ * Build the initial prompt for the agent based on launch context
433
+ */
434
+ function buildInitialPrompt(context: AgentLaunchContext): string | null {
435
+ if (!context.url) return null;
436
+
437
+ return `[jack] Project "${context.projectName}" is live at ${context.url} - say hi and let's build!`;
438
+ }
439
+
420
440
  function buildLaunchCommand(
421
441
  launch: AgentLaunchConfig,
422
442
  projectDir: string,
423
- ):
424
- | {
425
- command: string;
426
- args: string[];
427
- options: { cwd?: string; stdio: "inherit" | "ignore"; detached?: boolean };
428
- waitForExit: boolean;
429
- }
430
- | null {
443
+ context?: AgentLaunchContext,
444
+ ): {
445
+ command: string;
446
+ args: string[];
447
+ options: { cwd?: string; stdio: "inherit" | "ignore"; detached?: boolean };
448
+ waitForExit: boolean;
449
+ } | null {
431
450
  if (launch.type !== "cli") return null;
432
451
 
452
+ const args = launch.args ? [...launch.args] : [];
453
+
454
+ // Add initial prompt if context is provided
455
+ if (context) {
456
+ const initialPrompt = buildInitialPrompt(context);
457
+ if (initialPrompt) {
458
+ args.push(initialPrompt);
459
+ }
460
+ }
461
+
433
462
  return {
434
463
  command: launch.command,
435
- args: launch.args ?? [],
464
+ args,
436
465
  options: { cwd: projectDir, stdio: "inherit" },
437
466
  waitForExit: true,
438
467
  };
@@ -441,14 +470,16 @@ function buildLaunchCommand(
441
470
  export async function launchAgent(
442
471
  launch: AgentLaunchConfig,
443
472
  projectDir: string,
473
+ context?: AgentLaunchContext,
444
474
  ): Promise<{ success: boolean; error?: string; command?: string[]; exitCode?: number | null }> {
445
- const launchCommand = buildLaunchCommand(launch, projectDir);
475
+ const launchCommand = buildLaunchCommand(launch, projectDir, context);
446
476
  if (!launchCommand) {
447
477
  return { success: false, error: "No supported launch command found" };
448
478
  }
449
479
 
450
480
  const { command, args, options, waitForExit } = launchCommand;
451
481
  const displayCommand = [command, ...args];
482
+ restoreTty();
452
483
 
453
484
  return await new Promise((resolve) => {
454
485
  const child = spawn(command, args, options);
@@ -543,3 +574,340 @@ export function getDefaultPreferredAgent(
543
574
 
544
575
  return withPriority[0]?.id ?? null;
545
576
  }
577
+
578
+ /**
579
+ * Agents that support one-shot mode (non-interactive execution)
580
+ */
581
+ export const ONE_SHOT_CAPABLE_AGENTS = ["claude-code", "codex"] as const;
582
+ type OneShotAgent = (typeof ONE_SHOT_CAPABLE_AGENTS)[number];
583
+
584
+ /**
585
+ * Check if an agent supports one-shot mode
586
+ */
587
+ export function isOneShotCapable(agentId: string): agentId is OneShotAgent {
588
+ return ONE_SHOT_CAPABLE_AGENTS.includes(agentId as OneShotAgent);
589
+ }
590
+
591
+ /**
592
+ * Get preferred agent for one-shot execution
593
+ * Returns null if no capable agent is available
594
+ */
595
+ export async function getOneShotAgent(): Promise<OneShotAgent | null> {
596
+ const preferred = await getPreferredAgent();
597
+
598
+ if (preferred && isOneShotCapable(preferred)) {
599
+ return preferred;
600
+ }
601
+
602
+ // Try to find any capable agent that's installed
603
+ for (const agentId of ONE_SHOT_CAPABLE_AGENTS) {
604
+ const launch = await getAgentLaunch(agentId);
605
+ if (launch) {
606
+ return agentId;
607
+ }
608
+ }
609
+
610
+ return null;
611
+ }
612
+
613
+ /**
614
+ * Build customization prompt for project personalization
615
+ */
616
+ function buildCustomizationPrompt(projectDir: string, intent: string): string {
617
+ return `You are customizing a new project based on this intent: "${intent}"
618
+
619
+ This is the first customization from a template. The project has been scaffolded but not yet personalized.
620
+
621
+ Instructions:
622
+ 1. Read AGENTS.md and CLAUDE.md for project context
623
+ 2. Modify code and configuration to match the intent
624
+ 3. Focus on code/config changes, not documentation
625
+ 4. Keep changes minimal and focused
626
+ 5. End with a short report:
627
+ - SUMMARY: 2-4 bullet points
628
+ - BLOCKER: none | <short reason>
629
+
630
+ Project directory: ${projectDir}
631
+
632
+ Please customize the project to match the intent.`;
633
+ }
634
+
635
+ /**
636
+ * Run agent in one-shot mode for project customization
637
+ */
638
+ export async function runAgentOneShot(
639
+ agentId: OneShotAgent,
640
+ projectDir: string,
641
+ intent: string,
642
+ reporter?: OneShotReporter,
643
+ ): Promise<{ success: boolean; error?: string }> {
644
+ const launch = await getAgentLaunch(agentId);
645
+ if (!launch || launch.type !== "cli") {
646
+ return { success: false, error: `Agent ${agentId} not available` };
647
+ }
648
+
649
+ const prompt = buildCustomizationPrompt(projectDir, intent);
650
+ const debugEnabled = isDebug();
651
+ const agentLabel = getAgentDefinition(agentId)?.name ?? agentId;
652
+
653
+ // Build command based on agent type
654
+ let args: string[];
655
+ let streamJson = false;
656
+ let jsonMode: "claude" | "codex" | null = null;
657
+ if (agentId === "claude-code") {
658
+ streamJson = true;
659
+ jsonMode = "claude";
660
+ args = [
661
+ "-p",
662
+ prompt,
663
+ "--permission-mode",
664
+ "acceptEdits",
665
+ "--output-format",
666
+ "stream-json",
667
+ "--include-partial-messages",
668
+ "--verbose",
669
+ ];
670
+ if (debugEnabled) {
671
+ args.push("--debug");
672
+ }
673
+ } else if (agentId === "codex") {
674
+ streamJson = true;
675
+ jsonMode = "codex";
676
+ args = ["exec", prompt, "--json", "--skip-git-repo-check"];
677
+ } else {
678
+ return { success: false, error: `Unsupported agent: ${agentId}` };
679
+ }
680
+
681
+ if (debugEnabled) {
682
+ debug("One-shot agent command", { command: launch.command, args, cwd: projectDir });
683
+ debug("One-shot prompt", prompt);
684
+ }
685
+
686
+ restoreTty();
687
+
688
+ return new Promise((resolve) => {
689
+ const child = spawn(launch.command, args, {
690
+ cwd: projectDir,
691
+ stdio: ["ignore", "pipe", "pipe"],
692
+ });
693
+
694
+ let stdoutBuffer = "";
695
+ let stderrBuffer = "";
696
+ let fullOutput = "";
697
+ const summaryLines: string[] = [];
698
+ let blockerMessage: string | null = null;
699
+ let statusMessage: string | null = null;
700
+
701
+ const reportSummary = () => {
702
+ if (!reporter) return;
703
+ if (summaryLines.length > 0) {
704
+ reporter.info(`Summary: ${summaryLines.join(" | ")}`);
705
+ }
706
+ if (blockerMessage && blockerMessage.toLowerCase() !== "none") {
707
+ reporter.warn(`Blocker: ${blockerMessage}`);
708
+ }
709
+ };
710
+
711
+ const updateStatus = (message: string) => {
712
+ if (!reporter?.status) return;
713
+ const next = `${agentLabel}: ${message}`;
714
+ if (next === statusMessage) return;
715
+ statusMessage = next;
716
+ reporter.status(next);
717
+ };
718
+
719
+ const extractSummary = (text: string) => {
720
+ const lines = text.split(/\r?\n/).map((line) => line.trim());
721
+ let inSummary = false;
722
+
723
+ for (const line of lines) {
724
+ if (!line) {
725
+ if (inSummary) {
726
+ inSummary = false;
727
+ }
728
+ continue;
729
+ }
730
+
731
+ if (line.toUpperCase().startsWith("SUMMARY:")) {
732
+ inSummary = true;
733
+ const after = line.slice("SUMMARY:".length).trim();
734
+ if (after) {
735
+ summaryLines.push(after.replace(/^[-•]\s*/, ""));
736
+ }
737
+ continue;
738
+ }
739
+
740
+ if (line.toUpperCase().startsWith("BLOCKER:")) {
741
+ blockerMessage = line.slice("BLOCKER:".length).trim();
742
+ inSummary = false;
743
+ continue;
744
+ }
745
+
746
+ if (inSummary) {
747
+ summaryLines.push(line.replace(/^[-•]\s*/, ""));
748
+ }
749
+ }
750
+ };
751
+
752
+ const handleClaudeLine = (line: string) => {
753
+ if (!line.trim()) return;
754
+ if (debugEnabled) {
755
+ process.stderr.write(`${line}\n`);
756
+ }
757
+ try {
758
+ const parsed = JSON.parse(line) as {
759
+ type?: string;
760
+ event?: {
761
+ type?: string;
762
+ content_block?: { type?: string; name?: string };
763
+ delta?: { type?: string; text_delta?: string };
764
+ };
765
+ result?: string;
766
+ is_error?: boolean;
767
+ permission_denials?: Array<{ reason?: string }>;
768
+ };
769
+
770
+ if (parsed.type === "result") {
771
+ if (typeof parsed.result === "string" && parsed.result.trim().length > 0) {
772
+ fullOutput = parsed.result;
773
+ }
774
+ if (parsed.permission_denials?.length && reporter) {
775
+ const details = parsed.permission_denials
776
+ .map((denial) => denial.reason)
777
+ .filter(Boolean)
778
+ .join(" | ");
779
+ reporter.warn(details ? `Permission denied: ${details}` : "Permission denied");
780
+ updateStatus("permission denied");
781
+ }
782
+ if (parsed.is_error) {
783
+ stderrBuffer = stderrBuffer || "Claude returned an error";
784
+ }
785
+ return;
786
+ }
787
+
788
+ if (parsed.type === "stream_event") {
789
+ const eventType = parsed.event?.type;
790
+ const blockType = parsed.event?.content_block?.type;
791
+ if (eventType === "message_start") {
792
+ updateStatus("thinking");
793
+ } else if (eventType === "content_block_start") {
794
+ if (blockType === "tool_use") {
795
+ const toolName = parsed.event?.content_block?.name;
796
+ updateStatus(toolName ? `running ${toolName}` : "running tool");
797
+ } else if (blockType === "text") {
798
+ updateStatus("responding");
799
+ }
800
+ } else if (eventType === "message_stop") {
801
+ updateStatus("finalizing");
802
+ }
803
+ const delta = parsed.event?.delta;
804
+ if (delta?.type === "text_delta" && delta.text_delta) {
805
+ fullOutput += delta.text_delta;
806
+ }
807
+ }
808
+ } catch {
809
+ // Ignore malformed lines in non-debug mode
810
+ }
811
+ };
812
+
813
+ const handleCodexLine = (line: string) => {
814
+ if (!line.trim()) return;
815
+ if (debugEnabled) {
816
+ process.stderr.write(`${line}\n`);
817
+ }
818
+ try {
819
+ const parsed = JSON.parse(line) as {
820
+ type?: string;
821
+ item?: { type?: string; text?: string };
822
+ error?: { message?: string };
823
+ };
824
+
825
+ if (parsed.type === "thread.started") {
826
+ updateStatus("starting");
827
+ }
828
+ if (parsed.type === "turn.started") {
829
+ updateStatus("thinking");
830
+ }
831
+
832
+ if (parsed.type === "item.completed" && parsed.item?.type === "agent_message") {
833
+ if (parsed.item.text) {
834
+ fullOutput += parsed.item.text;
835
+ }
836
+ updateStatus("responding");
837
+ }
838
+ if (parsed.type === "item.completed" && parsed.item?.type === "reasoning") {
839
+ updateStatus("reasoning");
840
+ }
841
+
842
+ if (parsed.type === "error") {
843
+ if (parsed.error?.message) {
844
+ stderrBuffer = parsed.error.message;
845
+ } else {
846
+ stderrBuffer = stderrBuffer || "Codex returned an error";
847
+ }
848
+ updateStatus("error");
849
+ }
850
+ if (parsed.type === "turn.completed") {
851
+ updateStatus("finalizing");
852
+ }
853
+ } catch {
854
+ // Ignore malformed lines in non-debug mode
855
+ }
856
+ };
857
+
858
+ const handleJsonLine =
859
+ jsonMode === "claude" ? handleClaudeLine : jsonMode === "codex" ? handleCodexLine : null;
860
+
861
+ child.stdout.on("data", (chunk) => {
862
+ const text = chunk.toString();
863
+ if (!streamJson) {
864
+ fullOutput += text;
865
+ if (debugEnabled) {
866
+ process.stderr.write(text);
867
+ }
868
+ return;
869
+ }
870
+
871
+ stdoutBuffer += text;
872
+ let newlineIndex = stdoutBuffer.indexOf("\n");
873
+ while (newlineIndex !== -1) {
874
+ const line = stdoutBuffer.slice(0, newlineIndex);
875
+ stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
876
+ handleJsonLine?.(line);
877
+ newlineIndex = stdoutBuffer.indexOf("\n");
878
+ }
879
+ });
880
+
881
+ child.stderr.on("data", (chunk) => {
882
+ const text = chunk.toString();
883
+ stderrBuffer += text;
884
+ if (debugEnabled) {
885
+ process.stderr.write(text);
886
+ }
887
+ });
888
+
889
+ child.once("error", (err) => {
890
+ resolve({ success: false, error: err.message });
891
+ });
892
+
893
+ child.once("exit", (code) => {
894
+ if (streamJson && stdoutBuffer.trim().length > 0) {
895
+ handleJsonLine?.(stdoutBuffer);
896
+ stdoutBuffer = "";
897
+ }
898
+
899
+ const exitOk = code === 0;
900
+ if (!debugEnabled && fullOutput.trim().length > 0) {
901
+ extractSummary(fullOutput);
902
+ reportSummary();
903
+ }
904
+
905
+ if (!exitOk && stderrBuffer.trim().length > 0) {
906
+ resolve({ success: false, error: stderrBuffer.trim() });
907
+ return;
908
+ }
909
+
910
+ resolve(exitOk ? { success: true } : { success: false, error: `Exit code ${code}` });
911
+ });
912
+ });
913
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Asset hash computation utilities for content-addressable asset management.
3
+ * Uses SHA-256 hashing with base64 content and file extension.
4
+ */
5
+
6
+ /**
7
+ * Represents a single entry in the asset manifest.
8
+ */
9
+ export interface AssetManifestEntry {
10
+ hash: string;
11
+ size: number;
12
+ }
13
+
14
+ /**
15
+ * Maps asset paths to their manifest entries.
16
+ */
17
+ export type AssetManifest = Record<string, AssetManifestEntry>;
18
+
19
+ /**
20
+ * Computes a content-addressable hash for an asset.
21
+ *
22
+ * Algorithm: SHA-256(base64(content) + extension).slice(0, 32)
23
+ * - Extension is extracted from filePath without the leading dot
24
+ * - Uses Web Crypto API for SHA-256 computation
25
+ *
26
+ * @param content - The raw binary content of the asset
27
+ * @param filePath - The file path used to extract the extension
28
+ * @returns A 32-character hex hash string
29
+ */
30
+ export async function computeAssetHash(content: Uint8Array, filePath: string): Promise<string> {
31
+ // Extract extension without the dot (e.g., "js" not ".js")
32
+ const extension = filePath.includes(".") ? filePath.split(".").pop() || "" : "";
33
+
34
+ // Convert content to base64
35
+ const base64 = Buffer.from(content).toString("base64");
36
+
37
+ // Create hash input: base64 content + extension
38
+ const hashInput = new TextEncoder().encode(base64 + extension);
39
+
40
+ // Compute SHA-256 hash using Web Crypto API
41
+ const hashBuffer = await crypto.subtle.digest("SHA-256", hashInput);
42
+
43
+ // Convert to hex string and truncate to 32 characters
44
+ const hashArray = new Uint8Array(hashBuffer);
45
+ const hashHex = Array.from(hashArray)
46
+ .map((b) => b.toString(16).padStart(2, "0"))
47
+ .join("");
48
+
49
+ return hashHex.slice(0, 32);
50
+ }
@@ -0,0 +1,115 @@
1
+ import { getAuthApiUrl } from "./constants.ts";
2
+ import { type AuthCredentials, getCredentials, isTokenExpired, saveCredentials } from "./store.ts";
3
+
4
+ export interface DeviceAuthResponse {
5
+ device_code: string;
6
+ user_code: string;
7
+ verification_uri: string;
8
+ verification_uri_complete: string;
9
+ expires_in: number;
10
+ interval: number;
11
+ }
12
+
13
+ export interface TokenResponse {
14
+ access_token: string;
15
+ refresh_token: string;
16
+ expires_in: number;
17
+ user: {
18
+ id: string;
19
+ email: string;
20
+ first_name: string | null;
21
+ last_name: string | null;
22
+ };
23
+ }
24
+
25
+ export async function startDeviceAuth(): Promise<DeviceAuthResponse> {
26
+ const response = await fetch(`${getAuthApiUrl()}/auth/device/authorize`, {
27
+ method: "POST",
28
+ });
29
+
30
+ if (!response.ok) {
31
+ const errorBody = (await response.json().catch(() => ({}))) as { message?: string };
32
+ throw new Error(errorBody.message || "Failed to start device authorization");
33
+ }
34
+
35
+ return response.json() as Promise<DeviceAuthResponse>;
36
+ }
37
+
38
+ export async function pollDeviceToken(deviceCode: string): Promise<TokenResponse | null> {
39
+ const response = await fetch(`${getAuthApiUrl()}/auth/device/token`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ device_code: deviceCode }),
43
+ });
44
+
45
+ if (response.status === 202) {
46
+ return null;
47
+ }
48
+
49
+ if (response.status === 410) {
50
+ throw new Error("Device code expired. Please try again.");
51
+ }
52
+
53
+ if (!response.ok) {
54
+ const errorBody = (await response.json().catch(() => ({}))) as { message?: string };
55
+ throw new Error(errorBody.message || "Failed to get token");
56
+ }
57
+
58
+ return response.json() as Promise<TokenResponse>;
59
+ }
60
+
61
+ export async function refreshToken(refreshTokenValue: string): Promise<TokenResponse> {
62
+ const response = await fetch(`${getAuthApiUrl()}/auth/refresh`, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ refresh_token: refreshTokenValue }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error("Failed to refresh token. Please login again.");
70
+ }
71
+
72
+ return response.json() as Promise<TokenResponse>;
73
+ }
74
+
75
+ export async function getValidAccessToken(): Promise<string | null> {
76
+ const creds = await getCredentials();
77
+ if (!creds) {
78
+ return null;
79
+ }
80
+
81
+ if (isTokenExpired(creds)) {
82
+ try {
83
+ const newTokens = await refreshToken(creds.refresh_token);
84
+ // Default to 5 minutes if expires_in not provided
85
+ const expiresIn = newTokens.expires_in ?? 300;
86
+ const newCreds: AuthCredentials = {
87
+ access_token: newTokens.access_token,
88
+ refresh_token: newTokens.refresh_token,
89
+ expires_at: Math.floor(Date.now() / 1000) + expiresIn,
90
+ user: newTokens.user,
91
+ };
92
+ await saveCredentials(newCreds);
93
+ return newCreds.access_token;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ return creds.access_token;
100
+ }
101
+
102
+ export async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
103
+ const token = await getValidAccessToken();
104
+ if (!token) {
105
+ throw new Error("Not authenticated. Run 'jack login' first.");
106
+ }
107
+
108
+ return fetch(url, {
109
+ ...options,
110
+ headers: {
111
+ ...options.headers,
112
+ Authorization: `Bearer ${token}`,
113
+ },
114
+ });
115
+ }
@@ -0,0 +1,5 @@
1
+ export const AUTH_API_URL = "https://auth.getjack.org";
2
+
3
+ export function getAuthApiUrl(): string {
4
+ return process.env.JACK_AUTH_URL || AUTH_API_URL;
5
+ }
@@ -0,0 +1,57 @@
1
+ import { JackError, JackErrorCode } from "../errors.ts";
2
+ import { info } from "../output.ts";
3
+ import { getValidAccessToken } from "./client.ts";
4
+ import { getCredentials } from "./store.ts";
5
+
6
+ /**
7
+ * Require auth - throws error if not logged in (for non-interactive contexts)
8
+ */
9
+ export async function requireAuth(): Promise<string> {
10
+ const token = await getValidAccessToken();
11
+
12
+ if (!token) {
13
+ throw new JackError(
14
+ JackErrorCode.AUTH_FAILED,
15
+ "Not logged in",
16
+ "Run 'jack login' to sign in to jack cloud",
17
+ );
18
+ }
19
+
20
+ return token;
21
+ }
22
+
23
+ /**
24
+ * Require auth with auto-login - starts login flow if needed (omakase style)
25
+ * Use this for interactive CLI commands that need auth.
26
+ */
27
+ export async function requireAuthOrLogin(): Promise<string> {
28
+ const token = await getValidAccessToken();
29
+
30
+ if (token) {
31
+ return token;
32
+ }
33
+
34
+ // Auto-start login flow
35
+ info("Signing in to jack cloud...");
36
+
37
+ const { default: login } = await import("../../commands/login.ts");
38
+ await login({ silent: true });
39
+
40
+ // After login, get the token
41
+ const newToken = await getValidAccessToken();
42
+ if (!newToken) {
43
+ throw new JackError(
44
+ JackErrorCode.AUTH_FAILED,
45
+ "Login failed",
46
+ "Please try again with 'jack login'",
47
+ );
48
+ }
49
+
50
+ console.error(""); // Space before continuing with original command
51
+ return newToken;
52
+ }
53
+
54
+ export async function getCurrentUser() {
55
+ const creds = await getCredentials();
56
+ return creds?.user ?? null;
57
+ }