@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 CHANGED
@@ -7,22 +7,29 @@ Run Instafy projects locally and connect them back to Instafy Studio — from an
7
7
  ## Quickstart
8
8
 
9
9
  0. Log in once: `instafy login`
10
+ - Opens a Studio URL; the CLI continues automatically after you sign in.
11
+ - Also enables Git auth (credential helper) for Instafy Git Service. Disable with `instafy login --no-git-setup`.
12
+ - Multiple accounts: `instafy login --profile work` (then bind folders with `instafy project init --profile work` or `instafy project profile work`).
10
13
  - Optional: set defaults with `instafy config set controller-url <url>` / `instafy config set studio-url <url>`
11
14
  1. Link a folder to a project:
12
15
  - VS Code: install the Instafy extension and run `Instafy: Link Workspace to Project`, or
13
- - Terminal: `instafy project:init`
14
- 2. Start the runtime: `instafy runtime:start`
16
+ - Terminal: `instafy project init`
17
+ 2. Start the runtime: `instafy runtime start`
15
18
  3. Check status / stop:
16
- - `instafy runtime:status`
17
- - `instafy runtime:stop`
19
+ - `instafy runtime status`
20
+ - `instafy runtime stop`
18
21
 
19
22
  ## Common commands
20
23
 
21
- - `instafy runtime:start` — start a local runtime (agent + origin).
22
- - `instafy runtime:status` — show health of the last started runtime.
23
- - `instafy runtime:stop` — stop the last started runtime.
24
- - `instafy tunnel`request a tunnel and forward a local port.
25
- - `instafy api:get` — query controller endpoints (conversations, messages, runs, etc).
24
+ - `instafy runtime start` — start a local runtime (agent + origin).
25
+ - `instafy runtime status` — show health of the last started runtime.
26
+ - `instafy runtime stop` — stop the last started runtime.
27
+ - `instafy git <args...>` run git commands against an Instafy canonical checkout (`.instafy/.git`) when present.
28
+ - `instafy tunnel start` — start a detached tunnel for a local port.
29
+ - `instafy tunnel list` — list local tunnels started by the CLI.
30
+ - `instafy tunnel logs <tunnelId> --follow` — tail tunnel logs.
31
+ - `instafy tunnel stop <tunnelId>` — stop + revoke a tunnel.
32
+ - `instafy api get` — query controller endpoints (conversations, messages, runs, etc).
26
33
 
27
34
  Run `instafy --help` for the full command list and options.
28
35
 
package/dist/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { resolveConfiguredAccessToken } from "./config.js";
3
+ import { formatAuthRejectedError } from "./errors.js";
3
4
  function normalizeUrl(raw) {
4
5
  const value = (raw ?? "").trim();
5
6
  if (!value)
@@ -123,6 +124,14 @@ export async function requestControllerApi(options) {
123
124
  const pretty = options.pretty !== false;
124
125
  const formattedBody = isJson ? maybePrettyPrintJson(responseText, pretty) : responseText;
125
126
  if (!response.ok) {
127
+ if (response.status === 401 || response.status === 403) {
128
+ throw formatAuthRejectedError({
129
+ status: response.status,
130
+ responseBody: responseText,
131
+ retryCommand: "instafy login",
132
+ advancedHint: "pass --access-token / --service-token, or set INSTAFY_ACCESS_TOKEN / INSTAFY_SERVICE_TOKEN",
133
+ });
134
+ }
126
135
  const prefix = `Request failed (${response.status} ${response.statusText})`;
127
136
  const suffix = formattedBody.trim() ? `: ${formattedBody}` : "";
128
137
  throw new Error(`${prefix}${suffix}`);
package/dist/auth.js CHANGED
@@ -1,7 +1,22 @@
1
1
  import kleur from "kleur";
2
+ import { randomBytes } from "node:crypto";
3
+ import * as http from "node:http";
4
+ import { createRequire } from "node:module";
2
5
  import { createInterface } from "node:readline/promises";
3
6
  import { stdin as input, stdout as output } from "node:process";
4
- import { clearInstafyCliConfig, getInstafyConfigPath, resolveConfiguredStudioUrl, resolveControllerUrl, resolveUserAccessToken, writeInstafyCliConfig, } from "./config.js";
7
+ import { clearInstafyCliConfig, clearInstafyProfileConfig, getInstafyConfigPath, getInstafyProfileConfigPath, resolveConfiguredControllerUrl, resolveConfiguredStudioUrl, resolveUserAccessToken, writeInstafyCliConfig, writeInstafyProfileConfig, } from "./config.js";
8
+ import { installGitCredentialHelper, uninstallGitCredentialHelper } from "./git-setup.js";
9
+ const require = createRequire(import.meta.url);
10
+ const cliVersion = (() => {
11
+ try {
12
+ const pkg = require("../package.json");
13
+ return typeof pkg.version === "string" ? pkg.version : "";
14
+ }
15
+ catch {
16
+ return "";
17
+ }
18
+ })();
19
+ const isStagingCli = cliVersion.includes("-staging.");
5
20
  function normalizeUrl(raw) {
6
21
  const trimmed = typeof raw === "string" ? raw.trim() : "";
7
22
  if (!trimmed) {
@@ -20,6 +35,26 @@ function normalizeToken(raw) {
20
35
  }
21
36
  return trimmed;
22
37
  }
38
+ async function loginWithPassword(params) {
39
+ const response = await fetch(`${params.supabaseUrl}/auth/v1/token?grant_type=password`, {
40
+ method: "POST",
41
+ headers: {
42
+ apikey: params.supabaseAnonKey,
43
+ "content-type": "application/json",
44
+ },
45
+ body: JSON.stringify({ email: params.email, password: params.password }),
46
+ });
47
+ if (!response.ok) {
48
+ const text = await response.text().catch(() => "");
49
+ throw new Error(`Supabase login failed (${response.status}): ${text}`);
50
+ }
51
+ const body = (await response.json());
52
+ const accessToken = typeof body["access_token"] === "string" ? body["access_token"] : null;
53
+ if (!accessToken) {
54
+ throw new Error("Supabase login response missing access_token");
55
+ }
56
+ return accessToken;
57
+ }
23
58
  function looksLikeLocalControllerUrl(controllerUrl) {
24
59
  try {
25
60
  const parsed = new URL(controllerUrl);
@@ -30,35 +65,337 @@ function looksLikeLocalControllerUrl(controllerUrl) {
30
65
  return controllerUrl.includes("127.0.0.1") || controllerUrl.includes("localhost");
31
66
  }
32
67
  }
33
- function deriveDefaultStudioUrl(controllerUrl) {
34
- if (looksLikeLocalControllerUrl(controllerUrl)) {
35
- return "http://localhost:5173";
68
+ async function isStudioHealthy(studioUrl, timeoutMs) {
69
+ const target = new URL("/cli/login", studioUrl).toString();
70
+ const abort = new AbortController();
71
+ const timeout = setTimeout(() => abort.abort(), timeoutMs);
72
+ timeout.unref?.();
73
+ try {
74
+ const response = await fetch(target, { signal: abort.signal });
75
+ return response.ok;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ finally {
81
+ clearTimeout(timeout);
82
+ }
83
+ }
84
+ async function resolveDefaultStudioUrl(controllerUrl) {
85
+ const hosted = "https://staging.instafy.dev";
86
+ if (isStagingCli) {
87
+ return hosted;
88
+ }
89
+ if (!looksLikeLocalControllerUrl(controllerUrl)) {
90
+ return hosted;
36
91
  }
37
- return "https://staging.instafy.dev";
92
+ const local = "http://localhost:5173";
93
+ const healthy = await isStudioHealthy(local, 250);
94
+ return healthy ? local : hosted;
95
+ }
96
+ async function isControllerHealthy(controllerUrl, timeoutMs) {
97
+ const target = `${controllerUrl.replace(/\/$/, "")}/healthz`;
98
+ const abort = new AbortController();
99
+ const timeout = setTimeout(() => abort.abort(), timeoutMs);
100
+ timeout.unref?.();
101
+ try {
102
+ const response = await fetch(target, { signal: abort.signal });
103
+ return response.ok;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ finally {
109
+ clearTimeout(timeout);
110
+ }
111
+ }
112
+ function readRequestBody(request, maxBytes = 1000000) {
113
+ return new Promise((resolve, reject) => {
114
+ let buffer = "";
115
+ request.setEncoding("utf8");
116
+ request.on("data", (chunk) => {
117
+ buffer += chunk;
118
+ if (buffer.length > maxBytes) {
119
+ reject(new Error("Request body too large."));
120
+ request.destroy();
121
+ }
122
+ });
123
+ request.on("end", () => resolve(buffer));
124
+ request.on("error", (error) => reject(error));
125
+ });
126
+ }
127
+ function applyCorsHeaders(request, response) {
128
+ const origin = typeof request.headers.origin === "string" ? request.headers.origin : "";
129
+ if (origin) {
130
+ response.setHeader("access-control-allow-origin", origin);
131
+ response.setHeader("vary", "origin");
132
+ }
133
+ else {
134
+ response.setHeader("access-control-allow-origin", "*");
135
+ }
136
+ response.setHeader("access-control-allow-methods", "POST, OPTIONS");
137
+ response.setHeader("access-control-allow-headers", "content-type");
138
+ // Private Network Access preflight (Chrome): allow https -> http://127.0.0.1 callbacks.
139
+ if (request.headers["access-control-request-private-network"] === "true") {
140
+ response.setHeader("access-control-allow-private-network", "true");
141
+ }
142
+ }
143
+ async function startCliLoginCallbackServer() {
144
+ const state = randomBytes(16).toString("hex");
145
+ let resolved = false;
146
+ let resolveToken = null;
147
+ let rejectToken = null;
148
+ const tokenPromise = new Promise((resolve, reject) => {
149
+ resolveToken = resolve;
150
+ rejectToken = reject;
151
+ });
152
+ const server = http.createServer(async (request, response) => {
153
+ applyCorsHeaders(request, response);
154
+ if (request.method === "OPTIONS") {
155
+ response.statusCode = 204;
156
+ response.end();
157
+ return;
158
+ }
159
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
160
+ if (request.method !== "POST" || url.pathname !== "/callback") {
161
+ response.statusCode = 404;
162
+ response.setHeader("content-type", "application/json");
163
+ response.end(JSON.stringify({ ok: false, error: "Not found" }));
164
+ return;
165
+ }
166
+ if (resolved) {
167
+ response.statusCode = 409;
168
+ response.setHeader("content-type", "application/json");
169
+ response.end(JSON.stringify({ ok: false, error: "Already completed" }));
170
+ return;
171
+ }
172
+ try {
173
+ const body = await readRequestBody(request);
174
+ const contentType = typeof request.headers["content-type"] === "string" ? request.headers["content-type"] : "";
175
+ let parsedToken = null;
176
+ let parsedState = null;
177
+ if (contentType.includes("application/json")) {
178
+ const json = JSON.parse(body);
179
+ parsedToken = typeof json.token === "string" ? json.token : null;
180
+ parsedState = typeof json.state === "string" ? json.state : null;
181
+ }
182
+ else {
183
+ const params = new URLSearchParams(body);
184
+ parsedToken = params.get("token");
185
+ parsedState = params.get("state");
186
+ }
187
+ const token = normalizeToken(parsedToken);
188
+ const receivedState = normalizeToken(parsedState);
189
+ if (!token) {
190
+ response.statusCode = 400;
191
+ response.setHeader("content-type", "application/json");
192
+ response.end(JSON.stringify({ ok: false, error: "Missing token" }));
193
+ return;
194
+ }
195
+ if (!receivedState || receivedState !== state) {
196
+ response.statusCode = 403;
197
+ response.setHeader("content-type", "application/json");
198
+ response.end(JSON.stringify({ ok: false, error: "Invalid state" }));
199
+ return;
200
+ }
201
+ resolved = true;
202
+ response.statusCode = 200;
203
+ response.setHeader("content-type", "application/json");
204
+ response.end(JSON.stringify({ ok: true }));
205
+ resolveToken?.(token);
206
+ resolveToken = null;
207
+ }
208
+ catch (error) {
209
+ response.statusCode = 500;
210
+ response.setHeader("content-type", "application/json");
211
+ response.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
212
+ }
213
+ });
214
+ await new Promise((resolve, reject) => {
215
+ server.once("error", reject);
216
+ server.listen(0, "127.0.0.1", () => resolve());
217
+ });
218
+ const address = server.address();
219
+ if (!address || typeof address !== "object" || typeof address.port !== "number") {
220
+ server.close();
221
+ throw new Error("Failed to start login callback server.");
222
+ }
223
+ return {
224
+ callbackUrl: `http://127.0.0.1:${address.port}/callback`,
225
+ state,
226
+ waitForToken: async (timeoutMs) => {
227
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
228
+ return tokenPromise;
229
+ }
230
+ const timeout = setTimeout(() => {
231
+ if (resolved)
232
+ return;
233
+ resolved = true;
234
+ rejectToken?.(new Error("Timed out waiting for browser login. Copy the token and paste it into the CLI instead."));
235
+ rejectToken = null;
236
+ }, timeoutMs);
237
+ timeout.unref?.();
238
+ try {
239
+ return await tokenPromise;
240
+ }
241
+ finally {
242
+ clearTimeout(timeout);
243
+ }
244
+ },
245
+ cancel: () => {
246
+ if (resolved)
247
+ return;
248
+ resolved = true;
249
+ rejectToken?.(new Error("Login cancelled."));
250
+ rejectToken = null;
251
+ resolveToken = null;
252
+ },
253
+ close: () => server.close(),
254
+ };
38
255
  }
39
256
  export async function login(options) {
40
- const controllerUrl = resolveControllerUrl({ controllerUrl: options.controllerUrl ?? null });
257
+ const profile = typeof options.profile === "string" && options.profile.trim() ? options.profile.trim() : null;
258
+ const explicitControllerUrl = normalizeUrl(options.controllerUrl ?? null) ??
259
+ normalizeUrl(process.env["INSTAFY_SERVER_URL"] ?? null) ??
260
+ normalizeUrl(process.env["CONTROLLER_BASE_URL"] ?? null) ??
261
+ (isStagingCli ? null : resolveConfiguredControllerUrl({ profile }));
262
+ const defaultLocalControllerUrl = "http://127.0.0.1:8788";
263
+ const defaultHostedControllerUrl = "https://controller.instafy.dev";
264
+ const controllerUrl = explicitControllerUrl ??
265
+ (isStagingCli
266
+ ? defaultHostedControllerUrl
267
+ : (await isControllerHealthy(defaultLocalControllerUrl, 250))
268
+ ? defaultLocalControllerUrl
269
+ : defaultHostedControllerUrl);
41
270
  const studioUrl = normalizeUrl(options.studioUrl ?? null) ??
42
271
  normalizeUrl(process.env["INSTAFY_STUDIO_URL"] ?? null) ??
43
- resolveConfiguredStudioUrl() ??
44
- deriveDefaultStudioUrl(controllerUrl);
272
+ (isStagingCli ? null : resolveConfiguredStudioUrl({ profile })) ??
273
+ (await resolveDefaultStudioUrl(controllerUrl));
45
274
  const url = new URL("/cli/login", studioUrl);
46
275
  url.searchParams.set("serverUrl", controllerUrl);
47
276
  if (options.json) {
48
- console.log(JSON.stringify({ url: url.toString(), configPath: getInstafyConfigPath() }));
277
+ console.log(JSON.stringify({
278
+ url: url.toString(),
279
+ profile,
280
+ configPath: profile ? getInstafyProfileConfigPath(profile) : getInstafyConfigPath(),
281
+ }));
49
282
  return;
50
283
  }
51
- console.log(kleur.green("Instafy CLI login"));
52
- console.log("");
53
- console.log("1) Open this URL in your browser:");
54
- console.log(kleur.cyan(url.toString()));
55
- console.log("");
56
- console.log("2) After you sign in, copy the token shown on that page.");
57
- console.log("");
284
+ const existing = resolveUserAccessToken({ profile });
58
285
  const provided = normalizeToken(options.token ?? null);
59
- const existing = resolveUserAccessToken();
60
286
  let token = provided;
287
+ let usedPasswordGrant = false;
288
+ if (!token) {
289
+ const email = normalizeToken(options.email ?? null) ?? normalizeToken(process.env["INSTAFY_LOGIN_EMAIL"] ?? null);
290
+ const password = normalizeToken(options.password ?? null) ??
291
+ normalizeToken(process.env["INSTAFY_LOGIN_PASSWORD"] ?? null);
292
+ if (email && password) {
293
+ const supabaseUrl = normalizeUrl(process.env["SUPABASE_URL"] ?? null) ??
294
+ normalizeUrl(process.env["VITE_SUPABASE_URL"] ?? null) ??
295
+ normalizeUrl(process.env["SUPABASE_PROJECT_URL"] ?? null);
296
+ const supabaseAnonKey = normalizeToken(process.env["SUPABASE_ANON_KEY"] ?? null) ??
297
+ normalizeToken(process.env["VITE_SUPABASE_ANON_KEY"] ?? null);
298
+ if (!supabaseUrl || !supabaseAnonKey) {
299
+ throw new Error("Email/password login requires Supabase env.\n\nSet:\n- SUPABASE_URL + SUPABASE_ANON_KEY (or VITE_SUPABASE_URL + VITE_SUPABASE_ANON_KEY)\n\nThen retry: instafy login --email <email> --password <password>");
300
+ }
301
+ token = await loginWithPassword({ supabaseUrl, supabaseAnonKey, email, password });
302
+ usedPasswordGrant = true;
303
+ }
304
+ }
305
+ let callbackServer = null;
306
+ if (!token) {
307
+ try {
308
+ callbackServer = await startCliLoginCallbackServer();
309
+ url.searchParams.set("cliCallbackUrl", callbackServer.callbackUrl);
310
+ url.searchParams.set("cliState", callbackServer.state);
311
+ }
312
+ catch {
313
+ callbackServer = null;
314
+ }
315
+ }
316
+ console.log(kleur.green("Instafy CLI login"));
317
+ console.log("");
318
+ if (!token) {
319
+ console.log("1) Open this URL in your browser:");
320
+ console.log(kleur.cyan(url.toString()));
321
+ console.log("");
322
+ if (callbackServer) {
323
+ console.log("2) Sign in — this terminal should continue automatically.");
324
+ console.log(kleur.gray("If it doesn't, copy the token shown on that page and paste it here."));
325
+ }
326
+ else {
327
+ console.log("2) After you sign in, copy the token shown on that page.");
328
+ }
329
+ console.log("");
330
+ }
331
+ else if (usedPasswordGrant) {
332
+ console.log(kleur.gray("Authenticated via email/password."));
333
+ console.log("");
334
+ }
335
+ if (!token && callbackServer) {
336
+ if (input.isTTY) {
337
+ console.log(kleur.gray("Waiting for browser login…"));
338
+ console.log(kleur.gray("If it doesn't continue, paste the token here and press Enter."));
339
+ console.log("");
340
+ const rl = createInterface({ input, output });
341
+ const abort = new AbortController();
342
+ const manualTokenPromise = (async () => {
343
+ while (true) {
344
+ const answer = await rl.question("Paste token (or wait): ", { signal: abort.signal });
345
+ const candidate = normalizeToken(answer);
346
+ if (candidate) {
347
+ return candidate;
348
+ }
349
+ }
350
+ })();
351
+ try {
352
+ const result = await Promise.race([
353
+ callbackServer
354
+ .waitForToken(10 * 60000)
355
+ .then((tokenValue) => ({ source: "browser", token: tokenValue })),
356
+ manualTokenPromise.then((tokenValue) => ({ source: "manual", token: tokenValue })),
357
+ ]);
358
+ token = result.token;
359
+ if (result.source === "browser") {
360
+ abort.abort();
361
+ }
362
+ else {
363
+ callbackServer.cancel();
364
+ }
365
+ }
366
+ catch (_error) {
367
+ // If browser login fails, keep waiting for a pasted token.
368
+ token = await manualTokenPromise;
369
+ callbackServer.cancel();
370
+ }
371
+ finally {
372
+ try {
373
+ rl.close();
374
+ }
375
+ catch {
376
+ // ignore
377
+ }
378
+ callbackServer.close();
379
+ callbackServer = null;
380
+ }
381
+ }
382
+ else {
383
+ try {
384
+ token = await callbackServer.waitForToken(10 * 60000);
385
+ }
386
+ catch (_error) {
387
+ // Ignore and fall back to manual copy/paste if possible.
388
+ }
389
+ finally {
390
+ callbackServer.close();
391
+ callbackServer = null;
392
+ }
393
+ }
394
+ }
61
395
  if (!token) {
396
+ if (!input.isTTY) {
397
+ throw new Error("No token provided.");
398
+ }
62
399
  const rl = createInterface({ input, output });
63
400
  try {
64
401
  token = normalizeToken(await rl.question("Paste token: "));
@@ -71,9 +408,25 @@ export async function login(options) {
71
408
  throw new Error("No token provided.");
72
409
  }
73
410
  if (!options.noStore) {
74
- writeInstafyCliConfig({ controllerUrl, studioUrl, accessToken: token });
411
+ if (profile) {
412
+ writeInstafyProfileConfig(profile, { controllerUrl, studioUrl, accessToken: token });
413
+ }
414
+ else {
415
+ writeInstafyCliConfig({ controllerUrl, studioUrl, accessToken: token });
416
+ }
75
417
  console.log("");
76
- console.log(kleur.green(`Saved token to ${getInstafyConfigPath()}`));
418
+ console.log(kleur.green(`Saved token to ${profile ? getInstafyProfileConfigPath(profile) : getInstafyConfigPath()}`));
419
+ if (options.gitSetup !== false) {
420
+ try {
421
+ const result = installGitCredentialHelper();
422
+ if (result.changed) {
423
+ console.log(kleur.green("Enabled git auth (credential helper installed)."));
424
+ }
425
+ }
426
+ catch (error) {
427
+ console.log(kleur.yellow(`Warning: failed to configure git credential helper: ${error instanceof Error ? error.message : String(error)}`));
428
+ }
429
+ }
77
430
  }
78
431
  else if (existing) {
79
432
  console.log("");
@@ -81,14 +434,30 @@ export async function login(options) {
81
434
  }
82
435
  console.log("");
83
436
  console.log("Next:");
84
- console.log(`- ${kleur.cyan("instafy project:init")}`);
85
- console.log(`- ${kleur.cyan("instafy runtime:start")}`);
437
+ console.log(`- ${kleur.cyan("instafy project init")}`);
438
+ console.log(`- ${kleur.cyan("instafy runtime start")}`);
86
439
  }
87
440
  export async function logout(options) {
88
- clearInstafyCliConfig(["accessToken"]);
441
+ if (options?.profile) {
442
+ clearInstafyProfileConfig(options.profile, ["accessToken"]);
443
+ }
444
+ else {
445
+ clearInstafyCliConfig(["accessToken"]);
446
+ try {
447
+ uninstallGitCredentialHelper();
448
+ }
449
+ catch {
450
+ // ignore git helper cleanup failures
451
+ }
452
+ }
89
453
  if (options?.json) {
90
454
  console.log(JSON.stringify({ ok: true }));
91
455
  return;
92
456
  }
93
- console.log(kleur.green("Logged out (cleared saved access token)."));
457
+ if (options?.profile) {
458
+ console.log(kleur.green(`Logged out (cleared saved access token for profile "${options.profile}").`));
459
+ }
460
+ else {
461
+ console.log(kleur.green("Logged out (cleared saved access token)."));
462
+ }
94
463
  }