@instafy/cli 0.1.8 → 0.1.10

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/dist/config.js CHANGED
@@ -1,8 +1,25 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { findProjectManifest } from "./project-manifest.js";
6
+ const require = createRequire(import.meta.url);
7
+ const cliVersion = (() => {
8
+ try {
9
+ const pkg = require("../package.json");
10
+ return typeof pkg.version === "string" ? pkg.version : "";
11
+ }
12
+ catch {
13
+ return "";
14
+ }
15
+ })();
16
+ const isStagingCli = cliVersion.includes("-staging.");
4
17
  const INSTAFY_DIR = path.join(os.homedir(), ".instafy");
5
18
  const CONFIG_PATH = path.join(INSTAFY_DIR, "config.json");
19
+ const PROFILES_DIR = path.join(INSTAFY_DIR, "profiles");
20
+ const DEFAULT_LOCAL_CONTROLLER_URL = "http://127.0.0.1:8788";
21
+ const DEFAULT_HOSTED_CONTROLLER_URL = "https://controller.instafy.dev";
22
+ const DEFAULT_CONTROLLER_URL = isStagingCli ? DEFAULT_HOSTED_CONTROLLER_URL : DEFAULT_LOCAL_CONTROLLER_URL;
6
23
  function normalizeToken(value) {
7
24
  if (typeof value !== "string") {
8
25
  return null;
@@ -24,9 +41,45 @@ function normalizeUrl(value) {
24
41
  }
25
42
  return trimmed.replace(/\/$/, "");
26
43
  }
44
+ function normalizeProfileName(value) {
45
+ const trimmed = typeof value === "string" ? value.trim() : "";
46
+ if (!trimmed) {
47
+ return null;
48
+ }
49
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(trimmed)) {
50
+ throw new Error(`Invalid profile name "${value}". Use letters/numbers plus . _ - (max 64 chars).`);
51
+ }
52
+ if (trimmed.includes("..")) {
53
+ throw new Error(`Invalid profile name "${value}".`);
54
+ }
55
+ return trimmed;
56
+ }
27
57
  export function getInstafyConfigPath() {
28
58
  return CONFIG_PATH;
29
59
  }
60
+ export function getInstafyProfilesDirPath() {
61
+ return PROFILES_DIR;
62
+ }
63
+ export function getInstafyProfileConfigPath(profile) {
64
+ const normalized = normalizeProfileName(profile);
65
+ if (!normalized) {
66
+ throw new Error("Profile name is required.");
67
+ }
68
+ return path.join(PROFILES_DIR, `${normalized}.json`);
69
+ }
70
+ export function listInstafyProfileNames() {
71
+ try {
72
+ const entries = fs.readdirSync(PROFILES_DIR, { withFileTypes: true });
73
+ return entries
74
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
75
+ .map((entry) => entry.name.slice(0, -".json".length))
76
+ .filter(Boolean)
77
+ .sort((a, b) => a.localeCompare(b));
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
30
83
  export function readInstafyCliConfig() {
31
84
  try {
32
85
  const raw = fs.readFileSync(CONFIG_PATH, "utf8");
@@ -39,6 +92,32 @@ export function readInstafyCliConfig() {
39
92
  controllerUrl: normalizeUrl(typeof record.controllerUrl === "string" ? record.controllerUrl : null),
40
93
  studioUrl: normalizeUrl(typeof record.studioUrl === "string" ? record.studioUrl : null),
41
94
  accessToken: normalizeToken(typeof record.accessToken === "string" ? record.accessToken : null),
95
+ refreshToken: normalizeToken(typeof record.refreshToken === "string" ? record.refreshToken : null),
96
+ supabaseUrl: normalizeUrl(typeof record.supabaseUrl === "string" ? record.supabaseUrl : null),
97
+ supabaseAnonKey: normalizeToken(typeof record.supabaseAnonKey === "string" ? record.supabaseAnonKey : null),
98
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null,
99
+ };
100
+ }
101
+ catch {
102
+ return {};
103
+ }
104
+ }
105
+ export function readInstafyProfileConfig(profile) {
106
+ const filePath = getInstafyProfileConfigPath(profile);
107
+ try {
108
+ const raw = fs.readFileSync(filePath, "utf8");
109
+ const parsed = JSON.parse(raw);
110
+ if (!parsed || typeof parsed !== "object") {
111
+ return {};
112
+ }
113
+ const record = parsed;
114
+ return {
115
+ controllerUrl: normalizeUrl(typeof record.controllerUrl === "string" ? record.controllerUrl : null),
116
+ studioUrl: normalizeUrl(typeof record.studioUrl === "string" ? record.studioUrl : null),
117
+ accessToken: normalizeToken(typeof record.accessToken === "string" ? record.accessToken : null),
118
+ refreshToken: normalizeToken(typeof record.refreshToken === "string" ? record.refreshToken : null),
119
+ supabaseUrl: normalizeUrl(typeof record.supabaseUrl === "string" ? record.supabaseUrl : null),
120
+ supabaseAnonKey: normalizeToken(typeof record.supabaseAnonKey === "string" ? record.supabaseAnonKey : null),
42
121
  updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null,
43
122
  };
44
123
  }
@@ -52,6 +131,9 @@ export function writeInstafyCliConfig(update) {
52
131
  controllerUrl: normalizeUrl(update.controllerUrl ?? existing.controllerUrl ?? null),
53
132
  studioUrl: normalizeUrl(update.studioUrl ?? existing.studioUrl ?? null),
54
133
  accessToken: normalizeToken(update.accessToken ?? existing.accessToken ?? null),
134
+ refreshToken: normalizeToken(update.refreshToken ?? existing.refreshToken ?? null),
135
+ supabaseUrl: normalizeUrl(update.supabaseUrl ?? existing.supabaseUrl ?? null),
136
+ supabaseAnonKey: normalizeToken(update.supabaseAnonKey ?? existing.supabaseAnonKey ?? null),
55
137
  updatedAt: new Date().toISOString(),
56
138
  };
57
139
  fs.mkdirSync(INSTAFY_DIR, { recursive: true });
@@ -64,6 +146,28 @@ export function writeInstafyCliConfig(update) {
64
146
  }
65
147
  return next;
66
148
  }
149
+ export function writeInstafyProfileConfig(profile, update) {
150
+ const filePath = getInstafyProfileConfigPath(profile);
151
+ const existing = readInstafyProfileConfig(profile);
152
+ const next = {
153
+ controllerUrl: normalizeUrl(update.controllerUrl ?? existing.controllerUrl ?? null),
154
+ studioUrl: normalizeUrl(update.studioUrl ?? existing.studioUrl ?? null),
155
+ accessToken: normalizeToken(update.accessToken ?? existing.accessToken ?? null),
156
+ refreshToken: normalizeToken(update.refreshToken ?? existing.refreshToken ?? null),
157
+ supabaseUrl: normalizeUrl(update.supabaseUrl ?? existing.supabaseUrl ?? null),
158
+ supabaseAnonKey: normalizeToken(update.supabaseAnonKey ?? existing.supabaseAnonKey ?? null),
159
+ updatedAt: new Date().toISOString(),
160
+ };
161
+ fs.mkdirSync(PROFILES_DIR, { recursive: true });
162
+ fs.writeFileSync(filePath, JSON.stringify(next, null, 2), { encoding: "utf8" });
163
+ try {
164
+ fs.chmodSync(filePath, 0o600);
165
+ }
166
+ catch {
167
+ // ignore chmod failures (windows / unusual fs)
168
+ }
169
+ return next;
170
+ }
67
171
  export function clearInstafyCliConfig(keys) {
68
172
  if (!keys || keys.length === 0) {
69
173
  try {
@@ -81,31 +185,108 @@ export function clearInstafyCliConfig(keys) {
81
185
  }
82
186
  writeInstafyCliConfig(next);
83
187
  }
84
- export function resolveConfiguredControllerUrl() {
188
+ export function clearInstafyProfileConfig(profile, keys) {
189
+ const filePath = getInstafyProfileConfigPath(profile);
190
+ if (!keys || keys.length === 0) {
191
+ try {
192
+ fs.rmSync(filePath, { force: true });
193
+ }
194
+ catch {
195
+ // ignore
196
+ }
197
+ return;
198
+ }
199
+ const existing = readInstafyProfileConfig(profile);
200
+ const next = { ...existing };
201
+ for (const key of keys) {
202
+ next[key] = null;
203
+ }
204
+ writeInstafyProfileConfig(profile, next);
205
+ }
206
+ export function resolveActiveProfileName(params) {
207
+ const explicit = normalizeProfileName(params?.profile ?? null);
208
+ if (explicit) {
209
+ return explicit;
210
+ }
211
+ const fromEnv = normalizeProfileName(process.env["INSTAFY_PROFILE"] ?? null);
212
+ if (fromEnv) {
213
+ return fromEnv;
214
+ }
215
+ const cwd = (params?.cwd ?? process.cwd()).trim();
216
+ if (!cwd) {
217
+ return null;
218
+ }
219
+ const manifest = findProjectManifest(cwd).manifest;
220
+ return normalizeProfileName(manifest?.profile ?? null);
221
+ }
222
+ export function resolveConfiguredControllerUrl(params) {
223
+ const profile = resolveActiveProfileName(params);
224
+ if (profile) {
225
+ const configured = readInstafyProfileConfig(profile);
226
+ return normalizeUrl(configured.controllerUrl ?? null);
227
+ }
85
228
  const config = readInstafyCliConfig();
86
229
  return normalizeUrl(config.controllerUrl ?? null);
87
230
  }
88
- export function resolveConfiguredStudioUrl() {
231
+ export function resolveConfiguredStudioUrl(params) {
232
+ const profile = resolveActiveProfileName(params);
233
+ if (profile) {
234
+ const configured = readInstafyProfileConfig(profile);
235
+ return normalizeUrl(configured.studioUrl ?? null);
236
+ }
89
237
  const config = readInstafyCliConfig();
90
238
  return normalizeUrl(config.studioUrl ?? null);
91
239
  }
92
- export function resolveConfiguredAccessToken() {
240
+ export function resolveConfiguredAccessToken(params) {
241
+ const profile = resolveActiveProfileName(params);
242
+ if (profile) {
243
+ const configured = readInstafyProfileConfig(profile);
244
+ return normalizeToken(configured.accessToken ?? null);
245
+ }
93
246
  const config = readInstafyCliConfig();
94
247
  return normalizeToken(config.accessToken ?? null);
95
248
  }
96
249
  export function resolveControllerUrl(params) {
97
- const config = readInstafyCliConfig();
250
+ const profile = resolveActiveProfileName({ profile: params?.profile ?? null, cwd: params?.cwd ?? null });
251
+ const config = profile ? readInstafyProfileConfig(profile) : readInstafyCliConfig();
98
252
  return (normalizeUrl(params?.controllerUrl ?? null) ??
99
253
  normalizeUrl(process.env["INSTAFY_SERVER_URL"] ?? null) ??
100
254
  normalizeUrl(process.env["CONTROLLER_BASE_URL"] ?? null) ??
101
255
  normalizeUrl(config.controllerUrl ?? null) ??
102
- "http://127.0.0.1:8788");
256
+ DEFAULT_CONTROLLER_URL);
103
257
  }
104
258
  export function resolveUserAccessToken(params) {
105
- const config = readInstafyCliConfig();
259
+ const profile = resolveActiveProfileName({ profile: params?.profile ?? null, cwd: params?.cwd ?? null });
260
+ const config = profile ? readInstafyProfileConfig(profile) : readInstafyCliConfig();
106
261
  return (normalizeToken(params?.accessToken ?? null) ??
107
262
  normalizeToken(process.env["INSTAFY_ACCESS_TOKEN"] ?? null) ??
108
263
  normalizeToken(process.env["CONTROLLER_ACCESS_TOKEN"] ?? null) ??
264
+ normalizeToken(process.env["RUNTIME_ACCESS_TOKEN"] ?? null) ??
109
265
  normalizeToken(process.env["SUPABASE_ACCESS_TOKEN"] ?? null) ??
110
266
  normalizeToken(config.accessToken ?? null));
111
267
  }
268
+ export function resolveUserAccessTokenWithSource(params) {
269
+ const profile = resolveActiveProfileName({ profile: params?.profile ?? null, cwd: params?.cwd ?? null });
270
+ const config = profile ? readInstafyProfileConfig(profile) : readInstafyCliConfig();
271
+ const explicit = normalizeToken(params?.accessToken ?? null);
272
+ if (explicit) {
273
+ return { token: explicit, source: "explicit", profile };
274
+ }
275
+ const envKeys = [
276
+ "INSTAFY_ACCESS_TOKEN",
277
+ "CONTROLLER_ACCESS_TOKEN",
278
+ "RUNTIME_ACCESS_TOKEN",
279
+ "SUPABASE_ACCESS_TOKEN",
280
+ ];
281
+ for (const key of envKeys) {
282
+ const value = normalizeToken(process.env[key] ?? null);
283
+ if (value) {
284
+ return { token: value, source: "env", profile };
285
+ }
286
+ }
287
+ const stored = normalizeToken(config.accessToken ?? null);
288
+ if (stored) {
289
+ return { token: stored, source: "config", profile };
290
+ }
291
+ return { token: null, source: "none", profile };
292
+ }
@@ -0,0 +1,33 @@
1
+ import { extractControllerErrorMessage } from "./errors.js";
2
+ import { refreshStoredSupabaseSession } from "./supabase-session.js";
3
+ function shouldAttemptRefresh(status, body) {
4
+ if (status !== 401) {
5
+ return false;
6
+ }
7
+ const message = extractControllerErrorMessage(body) ?? "";
8
+ return message.toLowerCase().includes("expired");
9
+ }
10
+ function withBearer(init, token) {
11
+ const headers = new Headers(init?.headers);
12
+ headers.set("authorization", `Bearer ${token}`);
13
+ return { ...init, headers };
14
+ }
15
+ export async function fetchWithControllerAuth(params) {
16
+ const response = await fetch(params.url, withBearer(params.init, params.accessToken));
17
+ if (response.ok) {
18
+ return { response, accessToken: params.accessToken };
19
+ }
20
+ if (params.tokenSource !== "config") {
21
+ return { response, accessToken: params.accessToken };
22
+ }
23
+ const responseText = await response.clone().text().catch(() => "");
24
+ if (!shouldAttemptRefresh(response.status, responseText)) {
25
+ return { response, accessToken: params.accessToken };
26
+ }
27
+ const refreshed = await refreshStoredSupabaseSession({ profile: params.profile ?? null, cwd: params.cwd ?? null });
28
+ if (!refreshed?.accessToken) {
29
+ return { response, accessToken: params.accessToken };
30
+ }
31
+ const retry = await fetch(params.url, withBearer(params.init, refreshed.accessToken));
32
+ return { response: retry, accessToken: refreshed.accessToken };
33
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,63 @@
1
+ export function formatAuthRequiredError(params) {
2
+ const lines = ["Not authenticated. Run `instafy login` first."];
3
+ if (params?.retryCommand) {
4
+ lines.push("", `Then retry: ${params.retryCommand}`);
5
+ }
6
+ if (params?.advancedHint) {
7
+ lines.push("", `Advanced: ${params.advancedHint}`);
8
+ }
9
+ return new Error(lines.join("\n"));
10
+ }
11
+ export function extractControllerErrorMessage(body) {
12
+ const trimmed = body.trim();
13
+ if (!trimmed) {
14
+ return null;
15
+ }
16
+ try {
17
+ const parsed = JSON.parse(trimmed);
18
+ if (parsed && typeof parsed === "object") {
19
+ const record = parsed;
20
+ const message = typeof record.message === "string" ? record.message.trim() : "";
21
+ if (message)
22
+ return message;
23
+ const error = typeof record.error === "string" ? record.error.trim() : "";
24
+ if (error)
25
+ return error;
26
+ const hint = typeof record.hint === "string" ? record.hint.trim() : "";
27
+ if (hint)
28
+ return hint;
29
+ }
30
+ }
31
+ catch {
32
+ // ignore malformed json
33
+ }
34
+ return trimmed;
35
+ }
36
+ export function formatAuthRejectedError(params) {
37
+ const status = params?.status ?? 0;
38
+ const message = params?.responseBody ? extractControllerErrorMessage(params.responseBody) : null;
39
+ const normalized = (message ?? "").toLowerCase();
40
+ const headline = (() => {
41
+ if (status === 403) {
42
+ return "Access denied. Make sure you are signed into the right account (`instafy login`).";
43
+ }
44
+ if (status === 401 && normalized.includes("expired")) {
45
+ return "Your login has expired. Run `instafy login` again.";
46
+ }
47
+ if (status === 401) {
48
+ return "Not authenticated. Run `instafy login` again.";
49
+ }
50
+ return "Not authenticated. Run `instafy login` first.";
51
+ })();
52
+ const lines = [headline];
53
+ if (params?.retryCommand) {
54
+ lines.push("", `Then retry: ${params.retryCommand}`);
55
+ }
56
+ if (message) {
57
+ lines.push("", `Server: ${message}`);
58
+ }
59
+ if (params?.advancedHint) {
60
+ lines.push("", `Advanced: ${params.advancedHint}`);
61
+ }
62
+ return new Error(lines.join("\n"));
63
+ }
@@ -0,0 +1,205 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolveControllerUrl, resolveUserAccessToken } from "./config.js";
3
+ import { mintGitAccessToken } from "./git.js";
4
+ function parseCredentialRequest(raw) {
5
+ const request = {};
6
+ for (const line of raw.split(/\r?\n/)) {
7
+ if (!line.trim())
8
+ continue;
9
+ const idx = line.indexOf("=");
10
+ if (idx <= 0)
11
+ continue;
12
+ const key = line.slice(0, idx).trim();
13
+ const value = line.slice(idx + 1).trim();
14
+ if (!value)
15
+ continue;
16
+ if (key === "protocol")
17
+ request.protocol = value;
18
+ if (key === "host")
19
+ request.host = value;
20
+ if (key === "path")
21
+ request.path = value;
22
+ if (key === "url")
23
+ request.url = value;
24
+ if (key === "username")
25
+ request.username = value;
26
+ }
27
+ return request;
28
+ }
29
+ function normalizeHost(rawHost) {
30
+ const host = rawHost.trim();
31
+ if (!host)
32
+ return null;
33
+ const lowered = host.toLowerCase();
34
+ const bracketed = lowered.match(/^\[(.+)\](?::\d+)?$/);
35
+ if (bracketed) {
36
+ return { host: lowered, hostname: bracketed[1] ?? lowered };
37
+ }
38
+ const lastColon = lowered.lastIndexOf(":");
39
+ if (lastColon > 0) {
40
+ const possiblePort = lowered.slice(lastColon + 1);
41
+ if (/^\d+$/.test(possiblePort)) {
42
+ return { host: lowered, hostname: lowered.slice(0, lastColon) };
43
+ }
44
+ }
45
+ return { host: lowered, hostname: lowered };
46
+ }
47
+ function resolveRequestHost(request) {
48
+ if (request.host?.trim())
49
+ return request.host.trim();
50
+ if (request.url?.trim()) {
51
+ try {
52
+ const parsed = new URL(request.url.trim());
53
+ return parsed.host;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ function splitCsv(value) {
62
+ return (value ?? "")
63
+ .split(",")
64
+ .map((entry) => entry.trim().toLowerCase())
65
+ .filter(Boolean);
66
+ }
67
+ function isAllowedGitHost(rawHost) {
68
+ if (!rawHost)
69
+ return false;
70
+ const normalized = normalizeHost(rawHost);
71
+ if (!normalized)
72
+ return false;
73
+ const allowHosts = splitCsv(process.env["INSTAFY_GIT_HOSTS"]);
74
+ if (allowHosts.includes(normalized.host) || allowHosts.includes(normalized.hostname)) {
75
+ return true;
76
+ }
77
+ // Safe-by-default allow-list: Instafy domains and local dev.
78
+ if (normalized.hostname === "localhost" ||
79
+ normalized.hostname === "127.0.0.1" ||
80
+ normalized.hostname === "::1" ||
81
+ normalized.hostname === "host.docker.internal") {
82
+ return true;
83
+ }
84
+ // Local dev stack defaults to docker-compose service names.
85
+ if (normalized.hostname === "git-edge" || normalized.hostname.startsWith("git-shard")) {
86
+ return true;
87
+ }
88
+ return normalized.hostname.endsWith(".instafy.dev");
89
+ }
90
+ function normalizeRepoName(raw) {
91
+ const trimmed = raw.trim().replace(/^\/+/, "");
92
+ if (!trimmed)
93
+ return null;
94
+ const first = trimmed.split("/")[0] ?? "";
95
+ if (!first.endsWith(".git"))
96
+ return null;
97
+ const withoutSuffix = first.slice(0, -".git".length);
98
+ return withoutSuffix || null;
99
+ }
100
+ function isUuid(value) {
101
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
102
+ }
103
+ function parseProjectIdFromUrl(raw) {
104
+ const trimmed = raw.trim();
105
+ if (!trimmed)
106
+ return null;
107
+ try {
108
+ const parsed = new URL(trimmed);
109
+ const repo = normalizeRepoName(parsed.pathname);
110
+ return repo && isUuid(repo) ? repo : null;
111
+ }
112
+ catch {
113
+ // Not a WHATWG URL (e.g. scp-like).
114
+ }
115
+ const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
116
+ if (scpLike) {
117
+ const repo = normalizeRepoName(scpLike[2] ?? "");
118
+ return repo && isUuid(repo) ? repo : null;
119
+ }
120
+ return null;
121
+ }
122
+ function resolveProjectIdFromRequest(request) {
123
+ const fromPath = request.path ? normalizeRepoName(request.path) : null;
124
+ if (fromPath && isUuid(fromPath))
125
+ return fromPath;
126
+ const fromUrl = request.url ? parseProjectIdFromUrl(request.url) : null;
127
+ if (fromUrl)
128
+ return fromUrl;
129
+ return null;
130
+ }
131
+ function resolveProjectIdFromGitRemotes(host) {
132
+ const result = spawnSync("git", ["config", "--get-regexp", "^remote\\..*\\.url$"], {
133
+ encoding: "utf8",
134
+ });
135
+ if (result.status !== 0) {
136
+ return null;
137
+ }
138
+ const lines = (result.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
139
+ for (const line of lines) {
140
+ const parts = line.split(/\s+/, 2);
141
+ const url = parts[1] ?? "";
142
+ if (!url)
143
+ continue;
144
+ if (host) {
145
+ try {
146
+ const parsed = new URL(url);
147
+ if (parsed.host !== host)
148
+ continue;
149
+ }
150
+ catch {
151
+ // ignore non-url remotes when host is known
152
+ continue;
153
+ }
154
+ }
155
+ const projectId = parseProjectIdFromUrl(url);
156
+ if (projectId)
157
+ return projectId;
158
+ }
159
+ return null;
160
+ }
161
+ export async function runGitCredentialHelper(operation) {
162
+ const normalized = (operation ?? "").trim().toLowerCase();
163
+ if (!normalized) {
164
+ throw new Error("git credential helper requires an operation (get|store|erase)");
165
+ }
166
+ // We mint tokens on demand; no persistence needed.
167
+ if (normalized === "store" || normalized === "erase") {
168
+ return;
169
+ }
170
+ if (normalized !== "get") {
171
+ throw new Error(`unsupported git credential operation: ${operation}`);
172
+ }
173
+ const stdin = await new Promise((resolve) => {
174
+ let buffer = "";
175
+ process.stdin.setEncoding("utf8");
176
+ process.stdin.on("data", (chunk) => (buffer += chunk));
177
+ process.stdin.on("end", () => resolve(buffer));
178
+ process.stdin.resume();
179
+ });
180
+ const request = parseCredentialRequest(stdin);
181
+ const requestHost = resolveRequestHost(request);
182
+ if (!isAllowedGitHost(requestHost)) {
183
+ return;
184
+ }
185
+ const projectId = resolveProjectIdFromRequest(request) ??
186
+ resolveProjectIdFromGitRemotes(requestHost);
187
+ if (!projectId) {
188
+ return;
189
+ }
190
+ const controllerUrl = resolveControllerUrl({ controllerUrl: null });
191
+ const userAccessToken = resolveUserAccessToken({ accessToken: null });
192
+ if (!userAccessToken) {
193
+ throw new Error("Not authenticated. Run `instafy login` first.");
194
+ }
195
+ const abort = new AbortController();
196
+ const timeout = setTimeout(() => abort.abort(), 5000);
197
+ const minted = await mintGitAccessToken({
198
+ controllerUrl,
199
+ controllerAccessToken: userAccessToken,
200
+ projectId,
201
+ scopes: ["git.read", "git.write"],
202
+ signal: abort.signal,
203
+ }).finally(() => clearTimeout(timeout));
204
+ process.stdout.write(`username=instafy\npassword=${minted.token}\n`);
205
+ }
@@ -0,0 +1,56 @@
1
+ import { spawnSync } from "node:child_process";
2
+ const INSTAFY_GIT_HELPER_VALUE = "!instafy git credential";
3
+ function isLikelySameHelper(value) {
4
+ return value.includes("instafy git credential");
5
+ }
6
+ function runGit(args) {
7
+ const result = spawnSync("git", args, { encoding: "utf8" });
8
+ if (result.error) {
9
+ throw result.error;
10
+ }
11
+ return result;
12
+ }
13
+ export function isGitAvailable() {
14
+ try {
15
+ const result = runGit(["--version"]);
16
+ return result.status === 0;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export function installGitCredentialHelper() {
23
+ if (!isGitAvailable()) {
24
+ return { changed: false };
25
+ }
26
+ const existing = runGit(["config", "--global", "--get-all", "credential.helper"]);
27
+ const helpers = (existing.stdout ?? "")
28
+ .split(/\r?\n/)
29
+ .map((line) => line.trim())
30
+ .filter(Boolean);
31
+ if (helpers.some(isLikelySameHelper)) {
32
+ return { changed: false };
33
+ }
34
+ runGit(["config", "--global", "--add", "credential.helper", INSTAFY_GIT_HELPER_VALUE]);
35
+ return { changed: true };
36
+ }
37
+ export function uninstallGitCredentialHelper() {
38
+ if (!isGitAvailable()) {
39
+ return { changed: false };
40
+ }
41
+ const existing = runGit(["config", "--global", "--get-all", "credential.helper"]);
42
+ const helpers = (existing.stdout ?? "")
43
+ .split(/\r?\n/)
44
+ .map((line) => line.trim())
45
+ .filter(Boolean);
46
+ if (!helpers.some(isLikelySameHelper)) {
47
+ return { changed: false };
48
+ }
49
+ const remaining = helpers.filter((helper) => !isLikelySameHelper(helper));
50
+ // Remove all helpers, then re-add the ones we didn't own.
51
+ runGit(["config", "--global", "--unset-all", "credential.helper"]);
52
+ for (const helper of remaining) {
53
+ runGit(["config", "--global", "--add", "credential.helper", helper]);
54
+ }
55
+ return { changed: true };
56
+ }