@instafy/cli 0.1.8 → 0.1.9
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/README.md +16 -9
- package/dist/api.js +9 -0
- package/dist/auth.js +392 -23
- package/dist/config.js +150 -6
- package/dist/errors.js +63 -0
- package/dist/git-credential.js +205 -0
- package/dist/git-setup.js +56 -0
- package/dist/git-wrapper.js +502 -0
- package/dist/git.js +11 -5
- package/dist/index.js +293 -108
- package/dist/org.js +9 -4
- package/dist/project-manifest.js +24 -0
- package/dist/project.js +254 -29
- package/dist/rathole.js +14 -10
- package/dist/runtime.js +11 -24
- package/dist/tunnel.js +272 -7
- package/package.json +3 -1
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");
|
|
@@ -46,6 +99,26 @@ export function readInstafyCliConfig() {
|
|
|
46
99
|
return {};
|
|
47
100
|
}
|
|
48
101
|
}
|
|
102
|
+
export function readInstafyProfileConfig(profile) {
|
|
103
|
+
const filePath = getInstafyProfileConfigPath(profile);
|
|
104
|
+
try {
|
|
105
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
106
|
+
const parsed = JSON.parse(raw);
|
|
107
|
+
if (!parsed || typeof parsed !== "object") {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
const record = parsed;
|
|
111
|
+
return {
|
|
112
|
+
controllerUrl: normalizeUrl(typeof record.controllerUrl === "string" ? record.controllerUrl : null),
|
|
113
|
+
studioUrl: normalizeUrl(typeof record.studioUrl === "string" ? record.studioUrl : null),
|
|
114
|
+
accessToken: normalizeToken(typeof record.accessToken === "string" ? record.accessToken : null),
|
|
115
|
+
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
49
122
|
export function writeInstafyCliConfig(update) {
|
|
50
123
|
const existing = readInstafyCliConfig();
|
|
51
124
|
const next = {
|
|
@@ -64,6 +137,25 @@ export function writeInstafyCliConfig(update) {
|
|
|
64
137
|
}
|
|
65
138
|
return next;
|
|
66
139
|
}
|
|
140
|
+
export function writeInstafyProfileConfig(profile, update) {
|
|
141
|
+
const filePath = getInstafyProfileConfigPath(profile);
|
|
142
|
+
const existing = readInstafyProfileConfig(profile);
|
|
143
|
+
const next = {
|
|
144
|
+
controllerUrl: normalizeUrl(update.controllerUrl ?? existing.controllerUrl ?? null),
|
|
145
|
+
studioUrl: normalizeUrl(update.studioUrl ?? existing.studioUrl ?? null),
|
|
146
|
+
accessToken: normalizeToken(update.accessToken ?? existing.accessToken ?? null),
|
|
147
|
+
updatedAt: new Date().toISOString(),
|
|
148
|
+
};
|
|
149
|
+
fs.mkdirSync(PROFILES_DIR, { recursive: true });
|
|
150
|
+
fs.writeFileSync(filePath, JSON.stringify(next, null, 2), { encoding: "utf8" });
|
|
151
|
+
try {
|
|
152
|
+
fs.chmodSync(filePath, 0o600);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// ignore chmod failures (windows / unusual fs)
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
}
|
|
67
159
|
export function clearInstafyCliConfig(keys) {
|
|
68
160
|
if (!keys || keys.length === 0) {
|
|
69
161
|
try {
|
|
@@ -81,31 +173,83 @@ export function clearInstafyCliConfig(keys) {
|
|
|
81
173
|
}
|
|
82
174
|
writeInstafyCliConfig(next);
|
|
83
175
|
}
|
|
84
|
-
export function
|
|
176
|
+
export function clearInstafyProfileConfig(profile, keys) {
|
|
177
|
+
const filePath = getInstafyProfileConfigPath(profile);
|
|
178
|
+
if (!keys || keys.length === 0) {
|
|
179
|
+
try {
|
|
180
|
+
fs.rmSync(filePath, { force: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// ignore
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const existing = readInstafyProfileConfig(profile);
|
|
188
|
+
const next = { ...existing };
|
|
189
|
+
for (const key of keys) {
|
|
190
|
+
next[key] = null;
|
|
191
|
+
}
|
|
192
|
+
writeInstafyProfileConfig(profile, next);
|
|
193
|
+
}
|
|
194
|
+
export function resolveActiveProfileName(params) {
|
|
195
|
+
const explicit = normalizeProfileName(params?.profile ?? null);
|
|
196
|
+
if (explicit) {
|
|
197
|
+
return explicit;
|
|
198
|
+
}
|
|
199
|
+
const fromEnv = normalizeProfileName(process.env["INSTAFY_PROFILE"] ?? null);
|
|
200
|
+
if (fromEnv) {
|
|
201
|
+
return fromEnv;
|
|
202
|
+
}
|
|
203
|
+
const cwd = (params?.cwd ?? process.cwd()).trim();
|
|
204
|
+
if (!cwd) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const manifest = findProjectManifest(cwd).manifest;
|
|
208
|
+
return normalizeProfileName(manifest?.profile ?? null);
|
|
209
|
+
}
|
|
210
|
+
export function resolveConfiguredControllerUrl(params) {
|
|
211
|
+
const profile = resolveActiveProfileName(params);
|
|
212
|
+
if (profile) {
|
|
213
|
+
const configured = readInstafyProfileConfig(profile);
|
|
214
|
+
return normalizeUrl(configured.controllerUrl ?? null);
|
|
215
|
+
}
|
|
85
216
|
const config = readInstafyCliConfig();
|
|
86
217
|
return normalizeUrl(config.controllerUrl ?? null);
|
|
87
218
|
}
|
|
88
|
-
export function resolveConfiguredStudioUrl() {
|
|
219
|
+
export function resolveConfiguredStudioUrl(params) {
|
|
220
|
+
const profile = resolveActiveProfileName(params);
|
|
221
|
+
if (profile) {
|
|
222
|
+
const configured = readInstafyProfileConfig(profile);
|
|
223
|
+
return normalizeUrl(configured.studioUrl ?? null);
|
|
224
|
+
}
|
|
89
225
|
const config = readInstafyCliConfig();
|
|
90
226
|
return normalizeUrl(config.studioUrl ?? null);
|
|
91
227
|
}
|
|
92
|
-
export function resolveConfiguredAccessToken() {
|
|
228
|
+
export function resolveConfiguredAccessToken(params) {
|
|
229
|
+
const profile = resolveActiveProfileName(params);
|
|
230
|
+
if (profile) {
|
|
231
|
+
const configured = readInstafyProfileConfig(profile);
|
|
232
|
+
return normalizeToken(configured.accessToken ?? null);
|
|
233
|
+
}
|
|
93
234
|
const config = readInstafyCliConfig();
|
|
94
235
|
return normalizeToken(config.accessToken ?? null);
|
|
95
236
|
}
|
|
96
237
|
export function resolveControllerUrl(params) {
|
|
97
|
-
const
|
|
238
|
+
const profile = resolveActiveProfileName({ profile: params?.profile ?? null, cwd: params?.cwd ?? null });
|
|
239
|
+
const config = profile ? readInstafyProfileConfig(profile) : readInstafyCliConfig();
|
|
98
240
|
return (normalizeUrl(params?.controllerUrl ?? null) ??
|
|
99
241
|
normalizeUrl(process.env["INSTAFY_SERVER_URL"] ?? null) ??
|
|
100
242
|
normalizeUrl(process.env["CONTROLLER_BASE_URL"] ?? null) ??
|
|
101
243
|
normalizeUrl(config.controllerUrl ?? null) ??
|
|
102
|
-
|
|
244
|
+
DEFAULT_CONTROLLER_URL);
|
|
103
245
|
}
|
|
104
246
|
export function resolveUserAccessToken(params) {
|
|
105
|
-
const
|
|
247
|
+
const profile = resolveActiveProfileName({ profile: params?.profile ?? null, cwd: params?.cwd ?? null });
|
|
248
|
+
const config = profile ? readInstafyProfileConfig(profile) : readInstafyCliConfig();
|
|
106
249
|
return (normalizeToken(params?.accessToken ?? null) ??
|
|
107
250
|
normalizeToken(process.env["INSTAFY_ACCESS_TOKEN"] ?? null) ??
|
|
108
251
|
normalizeToken(process.env["CONTROLLER_ACCESS_TOKEN"] ?? null) ??
|
|
252
|
+
normalizeToken(process.env["RUNTIME_ACCESS_TOKEN"] ?? null) ??
|
|
109
253
|
normalizeToken(process.env["SUPABASE_ACCESS_TOKEN"] ?? null) ??
|
|
110
254
|
normalizeToken(config.accessToken ?? null));
|
|
111
255
|
}
|
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
|
+
}
|