@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/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 resolveConfiguredControllerUrl() {
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 config = readInstafyCliConfig();
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
- "http://127.0.0.1:8788");
244
+ DEFAULT_CONTROLLER_URL);
103
245
  }
104
246
  export function resolveUserAccessToken(params) {
105
- const config = readInstafyCliConfig();
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
+ }