@awak-app/simy-cli 0.1.0 → 0.1.2

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.
@@ -1,21 +1,55 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
 
5
- const SESSION_PATH = join(homedir(), ".simy", "session.json");
6
+ import { normalizeWebOrigin } from "./web-origin.js";
7
+
6
8
  const SESSION_TTL_MS = 48 * 60 * 60 * 1000;
7
9
 
8
- export function sessionPath() {
9
- return SESSION_PATH;
10
+ export function sessionPath(webOrigin, sessionRoot = defaultSessionRoot()) {
11
+ const origin = normalizeWebOrigin(webOrigin);
12
+ const originHash = createHash("sha256").update(origin).digest("hex");
13
+ return join(sessionRoot, "sessions", `${originHash}.json`);
10
14
  }
11
15
 
12
16
  export function expiresAtFromNow(now = Date.now()) {
13
17
  return new Date(now + SESSION_TTL_MS).toISOString();
14
18
  }
15
19
 
16
- export async function readSession() {
20
+ export async function readSession(webOrigin, sessionRoot = defaultSessionRoot()) {
21
+ const origin = normalizeWebOrigin(webOrigin);
22
+ const scoped = await readSessionFile(sessionPath(origin, sessionRoot));
23
+ if (sessionMatchesOrigin(scoped, origin)) return scoped;
24
+
25
+ const legacy = await readSessionFile(join(sessionRoot, "session.json"));
26
+ return sessionMatchesOrigin(legacy, origin) ? legacy : null;
27
+ }
28
+
29
+ export function isSessionValid(session, now = Date.now(), webOrigin = null) {
30
+ if (!session?.expires_at) return false;
31
+ if (webOrigin && !sessionMatchesOrigin(session, normalizeWebOrigin(webOrigin))) return false;
32
+ const expiresAt = Date.parse(session.expires_at);
33
+ return Number.isFinite(expiresAt) && expiresAt > now;
34
+ }
35
+
36
+ export async function writeSession(webOrigin, session, sessionRoot = defaultSessionRoot()) {
37
+ const origin = normalizeWebOrigin(webOrigin);
38
+ const targetPath = sessionPath(origin, sessionRoot);
39
+ await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
40
+ await writeFile(targetPath, `${JSON.stringify({ ...session, api_origin: origin }, null, 2)}\n`, {
41
+ encoding: "utf8",
42
+ mode: 0o600,
43
+ });
44
+ }
45
+
46
+ function defaultSessionRoot() {
47
+ return process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
48
+ }
49
+
50
+ async function readSessionFile(path) {
17
51
  try {
18
- const raw = await readFile(SESSION_PATH, "utf8");
52
+ const raw = await readFile(path, "utf8");
19
53
  const parsed = JSON.parse(raw);
20
54
  if (!parsed || typeof parsed !== "object") return null;
21
55
  if (typeof parsed.token !== "string" || typeof parsed.expires_at !== "string") return null;
@@ -26,16 +60,11 @@ export async function readSession() {
26
60
  }
27
61
  }
28
62
 
29
- export function isSessionValid(session, now = Date.now()) {
30
- if (!session?.expires_at) return false;
31
- const expiresAt = Date.parse(session.expires_at);
32
- return Number.isFinite(expiresAt) && expiresAt > now;
33
- }
34
-
35
- export async function writeSession(session) {
36
- await mkdir(dirname(SESSION_PATH), { recursive: true, mode: 0o700 });
37
- await writeFile(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`, {
38
- encoding: "utf8",
39
- mode: 0o600,
40
- });
63
+ function sessionMatchesOrigin(session, origin) {
64
+ if (typeof session?.api_origin !== "string") return false;
65
+ try {
66
+ return normalizeWebOrigin(session.api_origin) === origin;
67
+ } catch {
68
+ return false;
69
+ }
41
70
  }
package/src/web-api.js ADDED
@@ -0,0 +1,66 @@
1
+ export function webApiHeaders({ token } = {}, extraHeaders = {}) {
2
+ const headers = { ...extraHeaders };
3
+ if (typeof token === "string" && token) {
4
+ headers.Authorization = `Bearer ${token}`;
5
+ }
6
+ return headers;
7
+ }
8
+
9
+ const APP_DEV_ORIGIN = "https://app-dev.simy.one";
10
+ const APP_DEV_GATEWAY_BASE_URL = "https://app.simy.one/api/local-cli-gateway/";
11
+
12
+ export function resolveWebApiBaseUrl(webOrigin, requestedBaseUrl = null) {
13
+ const normalizedWebOrigin = new URL(webOrigin).origin;
14
+ if (!requestedBaseUrl) {
15
+ if (normalizedWebOrigin === APP_DEV_ORIGIN) {
16
+ throw new Error("SIMY Web did not provide the required dev CLI gateway.");
17
+ }
18
+ return new URL("/api/local-cli/", normalizedWebOrigin).href;
19
+ }
20
+
21
+ const requested = new URL(requestedBaseUrl);
22
+ requested.hash = "";
23
+ requested.search = "";
24
+ if (!requested.pathname.endsWith("/")) requested.pathname += "/";
25
+
26
+ const directBase = new URL("/api/local-cli/", normalizedWebOrigin).href;
27
+ if (requested.href === directBase) return directBase;
28
+ if (
29
+ normalizedWebOrigin === APP_DEV_ORIGIN &&
30
+ requested.href === APP_DEV_GATEWAY_BASE_URL
31
+ ) {
32
+ return APP_DEV_GATEWAY_BASE_URL;
33
+ }
34
+ throw new Error("SIMY Web provided an untrusted CLI API endpoint.");
35
+ }
36
+
37
+ export function webApiUrl(path, { webOrigin, apiBaseUrl = null }) {
38
+ const baseUrl = resolveWebApiBaseUrl(webOrigin, apiBaseUrl);
39
+ return new URL(String(path || "").replace(/^\/+/, ""), baseUrl);
40
+ }
41
+
42
+ export function sessionRequiresWebAuthorization(session, webOrigin) {
43
+ try {
44
+ resolveWebApiBaseUrl(webOrigin, session?.api_base_url);
45
+ return false;
46
+ } catch {
47
+ return true;
48
+ }
49
+ }
50
+
51
+ export function webApiErrorMessage(payload, status) {
52
+ const message = nestedErrorMessage(payload);
53
+ return message || `SIMY Web returned HTTP ${status}.`;
54
+ }
55
+
56
+ function nestedErrorMessage(value, seen = new Set()) {
57
+ if (typeof value === "string") return value.trim();
58
+ if (!value || typeof value !== "object" || seen.has(value)) return "";
59
+ seen.add(value);
60
+
61
+ for (const key of ["message", "detail", "error", "hint", "description"]) {
62
+ const message = nestedErrorMessage(value[key], seen);
63
+ if (message) return message;
64
+ }
65
+ return "";
66
+ }
@@ -0,0 +1,46 @@
1
+ export const DEFAULT_WEB_ORIGIN = "https://app.simy.one";
2
+
3
+ export function resolveWebOrigin(host, env = process.env) {
4
+ return normalizeWebOrigin(
5
+ host || env.SIMY_WEB_ORIGIN || env.SIMY_API_ORIGIN || DEFAULT_WEB_ORIGIN,
6
+ );
7
+ }
8
+
9
+ export function normalizeWebOrigin(value) {
10
+ if (typeof value !== "string" || !value.trim()) {
11
+ throw new Error("SIMY Web host must be an absolute http(s) URL.");
12
+ }
13
+
14
+ let url;
15
+ try {
16
+ url = new URL(value.trim());
17
+ } catch {
18
+ throw new Error("SIMY Web host must be an absolute http(s) URL.");
19
+ }
20
+
21
+ if (!["http:", "https:"].includes(url.protocol)) {
22
+ throw new Error("SIMY Web host must use http or https.");
23
+ }
24
+ if (url.username || url.password) {
25
+ throw new Error("SIMY Web host must not contain credentials.");
26
+ }
27
+ if ((url.pathname && url.pathname !== "/") || url.search || url.hash) {
28
+ throw new Error("SIMY Web host must contain only an origin, without a path or query.");
29
+ }
30
+
31
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
32
+ if (url.protocol === "http:" && !loopbackHosts.has(url.hostname)) {
33
+ throw new Error("SIMY Web host must use https unless it is a loopback address.");
34
+ }
35
+
36
+ return url.origin;
37
+ }
38
+
39
+ export function isRequestOriginAllowed(requestOrigin, webOrigin) {
40
+ if (!requestOrigin) return true;
41
+ try {
42
+ return normalizeWebOrigin(requestOrigin) === webOrigin;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
@@ -0,0 +1,37 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function discoverWorkspace(cwd = process.cwd()) {
7
+ try {
8
+ const [{ stdout: root }, { stdout: remote }, { stdout: branch }] = await Promise.all([
9
+ execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd }),
10
+ execFileAsync("git", ["remote", "get-url", "origin"], { cwd }),
11
+ execFileAsync("git", ["branch", "--show-current"], { cwd }),
12
+ ]);
13
+ const repository = normalizeGitHubRemote(remote);
14
+ if (!repository) return emptyWorkspace(cwd);
15
+ return {
16
+ repository,
17
+ branch: String(branch || "").trim() || "dev",
18
+ localPath: String(root || "").trim(),
19
+ };
20
+ } catch {
21
+ return emptyWorkspace(cwd);
22
+ }
23
+ }
24
+
25
+ export function normalizeGitHubRemote(value) {
26
+ const repository = String(value || "")
27
+ .trim()
28
+ .replace(/^git@github\.com:/, "")
29
+ .replace(/^ssh:\/\/git@github\.com\//, "")
30
+ .replace(/^https?:\/\/github\.com\//, "")
31
+ .replace(/\.git$/, "");
32
+ return repository.includes("/") ? repository : null;
33
+ }
34
+
35
+ function emptyWorkspace(cwd) {
36
+ return { repository: null, branch: "dev", localPath: cwd };
37
+ }