@instafy/cli 0.1.9 → 0.1.11

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/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
- import { resolveConfiguredAccessToken } from "./config.js";
2
+ import { resolveActiveProfileName, resolveConfiguredAccessToken } from "./config.js";
3
3
  import { formatAuthRejectedError } from "./errors.js";
4
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
4
5
  function normalizeUrl(raw) {
5
6
  const value = (raw ?? "").trim();
6
7
  if (!value)
@@ -13,15 +14,31 @@ function normalizeToken(raw) {
13
14
  const trimmed = raw.trim();
14
15
  return trimmed.length ? trimmed : null;
15
16
  }
16
- function resolveBearerToken(options) {
17
- return (normalizeToken(options.accessToken) ??
18
- normalizeToken(options.serviceToken) ??
19
- normalizeToken(process.env["CONTROLLER_ACCESS_TOKEN"]) ??
20
- normalizeToken(process.env["INSTAFY_ACCESS_TOKEN"]) ??
21
- normalizeToken(process.env["INSTAFY_SERVICE_TOKEN"]) ??
22
- normalizeToken(process.env["CONTROLLER_TOKEN"]) ??
23
- normalizeToken(process.env["SUPABASE_ACCESS_TOKEN"]) ??
24
- resolveConfiguredAccessToken());
17
+ function resolveBearerTokenWithSource(options) {
18
+ const cwd = process.cwd();
19
+ const profile = resolveActiveProfileName({ cwd });
20
+ const stored = resolveConfiguredAccessToken({ profile, cwd });
21
+ const explicit = normalizeToken(options.accessToken) ?? normalizeToken(options.serviceToken);
22
+ if (explicit) {
23
+ return { token: explicit, source: "explicit", profile };
24
+ }
25
+ const envKeys = [
26
+ "CONTROLLER_ACCESS_TOKEN",
27
+ "INSTAFY_ACCESS_TOKEN",
28
+ "INSTAFY_SERVICE_TOKEN",
29
+ "CONTROLLER_TOKEN",
30
+ "SUPABASE_ACCESS_TOKEN",
31
+ ];
32
+ for (const key of envKeys) {
33
+ const value = normalizeToken(process.env[key]);
34
+ if (value) {
35
+ return { token: value, source: "env", profile };
36
+ }
37
+ }
38
+ if (stored) {
39
+ return { token: stored, source: "config", profile };
40
+ }
41
+ return { token: null, source: "none", profile };
25
42
  }
26
43
  function parseKeyValue(raw) {
27
44
  const trimmed = raw.trim();
@@ -99,12 +116,10 @@ function maybePrettyPrintJson(text, pretty) {
99
116
  }
100
117
  export async function requestControllerApi(options) {
101
118
  const url = buildRequestUrl(options);
102
- const bearer = resolveBearerToken(options);
119
+ const resolved = resolveBearerTokenWithSource(options);
120
+ const bearer = resolved.token;
103
121
  const headers = new Headers();
104
122
  headers.set("accept", "application/json");
105
- if (bearer) {
106
- headers.set("authorization", `Bearer ${bearer}`);
107
- }
108
123
  for (const header of options.headers ?? []) {
109
124
  const { key, value } = parseHeader(header);
110
125
  headers.set(key, value);
@@ -113,11 +128,21 @@ export async function requestControllerApi(options) {
113
128
  if (body !== undefined && !headers.has("content-type")) {
114
129
  headers.set("content-type", "application/json");
115
130
  }
116
- const response = await fetch(url, {
131
+ const init = {
117
132
  method: options.method,
118
133
  headers,
119
134
  body,
120
- });
135
+ };
136
+ const response = bearer
137
+ ? (await fetchWithControllerAuth({
138
+ url: url.toString(),
139
+ init,
140
+ accessToken: bearer,
141
+ tokenSource: resolved.source,
142
+ profile: resolved.profile,
143
+ cwd: process.cwd(),
144
+ })).response
145
+ : await fetch(url, init);
121
146
  const responseText = await response.text().catch(() => "");
122
147
  const contentType = response.headers.get("content-type") ?? "";
123
148
  const isJson = contentType.includes("application/json") || contentType.includes("+json");
package/dist/auth.js CHANGED
@@ -53,7 +53,13 @@ async function loginWithPassword(params) {
53
53
  if (!accessToken) {
54
54
  throw new Error("Supabase login response missing access_token");
55
55
  }
56
- return accessToken;
56
+ const refreshToken = typeof body["refresh_token"] === "string" ? body["refresh_token"] : null;
57
+ return {
58
+ accessToken,
59
+ refreshToken: normalizeToken(refreshToken),
60
+ supabaseUrl: normalizeUrl(params.supabaseUrl),
61
+ supabaseAnonKey: normalizeToken(params.supabaseAnonKey),
62
+ };
57
63
  }
58
64
  function looksLikeLocalControllerUrl(controllerUrl) {
59
65
  try {
@@ -173,18 +179,47 @@ async function startCliLoginCallbackServer() {
173
179
  const body = await readRequestBody(request);
174
180
  const contentType = typeof request.headers["content-type"] === "string" ? request.headers["content-type"] : "";
175
181
  let parsedToken = null;
182
+ let parsedRefreshToken = null;
183
+ let parsedSupabaseUrl = null;
184
+ let parsedSupabaseAnonKey = null;
176
185
  let parsedState = null;
177
186
  if (contentType.includes("application/json")) {
178
187
  const json = JSON.parse(body);
179
- parsedToken = typeof json.token === "string" ? json.token : null;
188
+ parsedToken =
189
+ typeof json.token === "string"
190
+ ? json.token
191
+ : typeof json.accessToken === "string"
192
+ ? json.accessToken
193
+ : typeof json.access_token === "string"
194
+ ? json.access_token
195
+ : null;
196
+ parsedRefreshToken =
197
+ typeof json.refreshToken === "string"
198
+ ? json.refreshToken
199
+ : typeof json.refresh_token === "string"
200
+ ? json.refresh_token
201
+ : null;
202
+ parsedSupabaseUrl = typeof json.supabaseUrl === "string" ? json.supabaseUrl : null;
203
+ parsedSupabaseAnonKey =
204
+ typeof json.supabaseAnonKey === "string"
205
+ ? json.supabaseAnonKey
206
+ : typeof json.supabase_anon_key === "string"
207
+ ? json.supabase_anon_key
208
+ : null;
180
209
  parsedState = typeof json.state === "string" ? json.state : null;
181
210
  }
182
211
  else {
183
212
  const params = new URLSearchParams(body);
184
213
  parsedToken = params.get("token");
214
+ parsedRefreshToken = params.get("refreshToken") ?? params.get("refresh_token");
215
+ parsedSupabaseUrl = params.get("supabaseUrl") ?? params.get("supabase_url");
216
+ parsedSupabaseAnonKey = params.get("supabaseAnonKey") ?? params.get("supabase_anon_key");
185
217
  parsedState = params.get("state");
186
218
  }
187
219
  const token = normalizeToken(parsedToken);
220
+ const refreshToken = normalizeToken(parsedRefreshToken);
221
+ const supabaseUrl = normalizeUrl(parsedSupabaseUrl);
222
+ const supabaseAnonKey = normalizeToken(parsedSupabaseAnonKey);
188
223
  const receivedState = normalizeToken(parsedState);
189
224
  if (!token) {
190
225
  response.statusCode = 400;
@@ -202,7 +237,12 @@ async function startCliLoginCallbackServer() {
202
237
  response.statusCode = 200;
203
238
  response.setHeader("content-type", "application/json");
204
239
  response.end(JSON.stringify({ ok: true }));
205
- resolveToken?.(token);
240
+ resolveToken?.({
241
+ accessToken: token,
242
+ refreshToken,
243
+ supabaseUrl,
244
+ supabaseAnonKey,
245
+ });
206
246
  resolveToken = null;
207
247
  }
208
248
  catch (error) {
@@ -283,9 +323,11 @@ export async function login(options) {
283
323
  }
284
324
  const existing = resolveUserAccessToken({ profile });
285
325
  const provided = normalizeToken(options.token ?? null);
286
- let token = provided;
326
+ let authPayload = provided
327
+ ? { accessToken: provided, refreshToken: null, supabaseUrl: null, supabaseAnonKey: null }
328
+ : null;
287
329
  let usedPasswordGrant = false;
288
- if (!token) {
330
+ if (!authPayload) {
289
331
  const email = normalizeToken(options.email ?? null) ?? normalizeToken(process.env["INSTAFY_LOGIN_EMAIL"] ?? null);
290
332
  const password = normalizeToken(options.password ?? null) ??
291
333
  normalizeToken(process.env["INSTAFY_LOGIN_PASSWORD"] ?? null);
@@ -298,12 +340,12 @@ export async function login(options) {
298
340
  if (!supabaseUrl || !supabaseAnonKey) {
299
341
  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
342
  }
301
- token = await loginWithPassword({ supabaseUrl, supabaseAnonKey, email, password });
343
+ authPayload = await loginWithPassword({ supabaseUrl, supabaseAnonKey, email, password });
302
344
  usedPasswordGrant = true;
303
345
  }
304
346
  }
305
347
  let callbackServer = null;
306
- if (!token) {
348
+ if (!authPayload) {
307
349
  try {
308
350
  callbackServer = await startCliLoginCallbackServer();
309
351
  url.searchParams.set("cliCallbackUrl", callbackServer.callbackUrl);
@@ -315,7 +357,7 @@ export async function login(options) {
315
357
  }
316
358
  console.log(kleur.green("Instafy CLI login"));
317
359
  console.log("");
318
- if (!token) {
360
+ if (!authPayload) {
319
361
  console.log("1) Open this URL in your browser:");
320
362
  console.log(kleur.cyan(url.toString()));
321
363
  console.log("");
@@ -332,7 +374,7 @@ export async function login(options) {
332
374
  console.log(kleur.gray("Authenticated via email/password."));
333
375
  console.log("");
334
376
  }
335
- if (!token && callbackServer) {
377
+ if (!authPayload && callbackServer) {
336
378
  if (input.isTTY) {
337
379
  console.log(kleur.gray("Waiting for browser login…"));
338
380
  console.log(kleur.gray("If it doesn't continue, paste the token here and press Enter."));
@@ -343,9 +385,40 @@ export async function login(options) {
343
385
  while (true) {
344
386
  const answer = await rl.question("Paste token (or wait): ", { signal: abort.signal });
345
387
  const candidate = normalizeToken(answer);
346
- if (candidate) {
347
- return candidate;
388
+ if (!candidate)
389
+ continue;
390
+ if (candidate.startsWith("{")) {
391
+ try {
392
+ const json = JSON.parse(candidate);
393
+ const accessToken = typeof json.accessToken === "string"
394
+ ? json.accessToken
395
+ : typeof json.access_token === "string"
396
+ ? json.access_token
397
+ : typeof json.token === "string"
398
+ ? json.token
399
+ : "";
400
+ if (accessToken.trim()) {
401
+ return {
402
+ accessToken: accessToken.trim(),
403
+ refreshToken: typeof json.refreshToken === "string"
404
+ ? json.refreshToken.trim()
405
+ : typeof json.refresh_token === "string"
406
+ ? json.refresh_token.trim()
407
+ : null,
408
+ supabaseUrl: typeof json.supabaseUrl === "string" ? json.supabaseUrl.trim() : null,
409
+ supabaseAnonKey: typeof json.supabaseAnonKey === "string"
410
+ ? json.supabaseAnonKey.trim()
411
+ : typeof json.supabase_anon_key === "string"
412
+ ? json.supabase_anon_key.trim()
413
+ : null,
414
+ };
415
+ }
416
+ }
417
+ catch {
418
+ // ignore and fall back to treating this as an access token
419
+ }
348
420
  }
421
+ return { accessToken: candidate, refreshToken: null, supabaseUrl: null, supabaseAnonKey: null };
349
422
  }
350
423
  })();
351
424
  try {
@@ -355,7 +428,7 @@ export async function login(options) {
355
428
  .then((tokenValue) => ({ source: "browser", token: tokenValue })),
356
429
  manualTokenPromise.then((tokenValue) => ({ source: "manual", token: tokenValue })),
357
430
  ]);
358
- token = result.token;
431
+ authPayload = result.token;
359
432
  if (result.source === "browser") {
360
433
  abort.abort();
361
434
  }
@@ -365,7 +438,7 @@ export async function login(options) {
365
438
  }
366
439
  catch (_error) {
367
440
  // If browser login fails, keep waiting for a pasted token.
368
- token = await manualTokenPromise;
441
+ authPayload = await manualTokenPromise;
369
442
  callbackServer.cancel();
370
443
  }
371
444
  finally {
@@ -381,7 +454,7 @@ export async function login(options) {
381
454
  }
382
455
  else {
383
456
  try {
384
- token = await callbackServer.waitForToken(10 * 60000);
457
+ authPayload = await callbackServer.waitForToken(10 * 60000);
385
458
  }
386
459
  catch (_error) {
387
460
  // Ignore and fall back to manual copy/paste if possible.
@@ -392,27 +465,69 @@ export async function login(options) {
392
465
  }
393
466
  }
394
467
  }
395
- if (!token) {
468
+ if (!authPayload?.accessToken) {
396
469
  if (!input.isTTY) {
397
470
  throw new Error("No token provided.");
398
471
  }
399
472
  const rl = createInterface({ input, output });
400
473
  try {
401
- token = normalizeToken(await rl.question("Paste token: "));
474
+ const raw = normalizeToken(await rl.question("Paste token: "));
475
+ if (raw) {
476
+ if (raw.startsWith("{")) {
477
+ try {
478
+ const json = JSON.parse(raw);
479
+ const accessToken = typeof json.accessToken === "string"
480
+ ? json.accessToken
481
+ : typeof json.access_token === "string"
482
+ ? json.access_token
483
+ : typeof json.token === "string"
484
+ ? json.token
485
+ : "";
486
+ if (accessToken.trim()) {
487
+ authPayload = {
488
+ accessToken: accessToken.trim(),
489
+ refreshToken: typeof json.refreshToken === "string"
490
+ ? json.refreshToken.trim()
491
+ : typeof json.refresh_token === "string"
492
+ ? json.refresh_token.trim()
493
+ : null,
494
+ supabaseUrl: typeof json.supabaseUrl === "string" ? json.supabaseUrl.trim() : null,
495
+ supabaseAnonKey: typeof json.supabaseAnonKey === "string"
496
+ ? json.supabaseAnonKey.trim()
497
+ : typeof json.supabase_anon_key === "string"
498
+ ? json.supabase_anon_key.trim()
499
+ : null,
500
+ };
501
+ }
502
+ }
503
+ catch {
504
+ // ignore and fall through to raw token
505
+ }
506
+ }
507
+ authPayload ?? (authPayload = { accessToken: raw, refreshToken: null, supabaseUrl: null, supabaseAnonKey: null });
508
+ }
402
509
  }
403
510
  finally {
404
511
  rl.close();
405
512
  }
406
513
  }
407
- if (!token) {
514
+ if (!authPayload?.accessToken) {
408
515
  throw new Error("No token provided.");
409
516
  }
410
517
  if (!options.noStore) {
518
+ const update = {
519
+ controllerUrl,
520
+ studioUrl,
521
+ accessToken: authPayload.accessToken,
522
+ refreshToken: authPayload.refreshToken ?? undefined,
523
+ supabaseUrl: authPayload.supabaseUrl ?? undefined,
524
+ supabaseAnonKey: authPayload.supabaseAnonKey ?? undefined,
525
+ };
411
526
  if (profile) {
412
- writeInstafyProfileConfig(profile, { controllerUrl, studioUrl, accessToken: token });
527
+ writeInstafyProfileConfig(profile, update);
413
528
  }
414
529
  else {
415
- writeInstafyCliConfig({ controllerUrl, studioUrl, accessToken: token });
530
+ writeInstafyCliConfig(update);
416
531
  }
417
532
  console.log("");
418
533
  console.log(kleur.green(`Saved token to ${profile ? getInstafyProfileConfigPath(profile) : getInstafyConfigPath()}`));
@@ -439,10 +554,10 @@ export async function login(options) {
439
554
  }
440
555
  export async function logout(options) {
441
556
  if (options?.profile) {
442
- clearInstafyProfileConfig(options.profile, ["accessToken"]);
557
+ clearInstafyProfileConfig(options.profile, ["accessToken", "refreshToken", "supabaseUrl", "supabaseAnonKey"]);
443
558
  }
444
559
  else {
445
- clearInstafyCliConfig(["accessToken"]);
560
+ clearInstafyCliConfig(["accessToken", "refreshToken", "supabaseUrl", "supabaseAnonKey"]);
446
561
  try {
447
562
  uninstallGitCredentialHelper();
448
563
  }
@@ -51,6 +51,7 @@ export function configList(options) {
51
51
  controllerUrl: config.controllerUrl ?? null,
52
52
  studioUrl: config.studioUrl ?? null,
53
53
  accessTokenSet: Boolean(config.accessToken),
54
+ refreshTokenSet: Boolean(config.refreshToken),
54
55
  updatedAt: config.updatedAt ?? null,
55
56
  };
56
57
  if (options?.json) {
@@ -62,6 +63,7 @@ export function configList(options) {
62
63
  console.log(`controller-url: ${payload.controllerUrl ?? kleur.yellow("(not set)")}`);
63
64
  console.log(`studio-url: ${payload.studioUrl ?? kleur.yellow("(not set)")}`);
64
65
  console.log(`access-token: ${payload.accessTokenSet ? kleur.green("(set)") : kleur.yellow("(not set)")}`);
66
+ console.log(`refresh-token: ${payload.refreshTokenSet ? kleur.green("(set)") : kleur.yellow("(not set)")}`);
65
67
  if (payload.updatedAt) {
66
68
  console.log(`updated-at: ${payload.updatedAt}`);
67
69
  }
package/dist/config.js CHANGED
@@ -92,6 +92,9 @@ export function readInstafyCliConfig() {
92
92
  controllerUrl: normalizeUrl(typeof record.controllerUrl === "string" ? record.controllerUrl : null),
93
93
  studioUrl: normalizeUrl(typeof record.studioUrl === "string" ? record.studioUrl : null),
94
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),
95
98
  updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null,
96
99
  };
97
100
  }
@@ -112,6 +115,9 @@ export function readInstafyProfileConfig(profile) {
112
115
  controllerUrl: normalizeUrl(typeof record.controllerUrl === "string" ? record.controllerUrl : null),
113
116
  studioUrl: normalizeUrl(typeof record.studioUrl === "string" ? record.studioUrl : null),
114
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),
115
121
  updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null,
116
122
  };
117
123
  }
@@ -125,6 +131,9 @@ export function writeInstafyCliConfig(update) {
125
131
  controllerUrl: normalizeUrl(update.controllerUrl ?? existing.controllerUrl ?? null),
126
132
  studioUrl: normalizeUrl(update.studioUrl ?? existing.studioUrl ?? null),
127
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),
128
137
  updatedAt: new Date().toISOString(),
129
138
  };
130
139
  fs.mkdirSync(INSTAFY_DIR, { recursive: true });
@@ -144,6 +153,9 @@ export function writeInstafyProfileConfig(profile, update) {
144
153
  controllerUrl: normalizeUrl(update.controllerUrl ?? existing.controllerUrl ?? null),
145
154
  studioUrl: normalizeUrl(update.studioUrl ?? existing.studioUrl ?? null),
146
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),
147
159
  updatedAt: new Date().toISOString(),
148
160
  };
149
161
  fs.mkdirSync(PROFILES_DIR, { recursive: true });
@@ -253,3 +265,28 @@ export function resolveUserAccessToken(params) {
253
265
  normalizeToken(process.env["SUPABASE_ACCESS_TOKEN"] ?? null) ??
254
266
  normalizeToken(config.accessToken ?? null));
255
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/org.js CHANGED
@@ -1,14 +1,19 @@
1
1
  import kleur from "kleur";
2
- import { resolveControllerUrl, resolveUserAccessToken } from "./config.js";
2
+ import { resolveControllerUrl, resolveUserAccessTokenWithSource } from "./config.js";
3
3
  import { formatAuthRejectedError, formatAuthRequiredError } from "./errors.js";
4
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
4
5
  export async function listOrganizations(params) {
5
6
  const controllerUrl = resolveControllerUrl({ controllerUrl: params.controllerUrl ?? null });
6
- const token = resolveUserAccessToken({ accessToken: params.accessToken ?? null });
7
- if (!token) {
7
+ const resolved = resolveUserAccessTokenWithSource({ accessToken: params.accessToken ?? null });
8
+ if (!resolved.token) {
8
9
  throw formatAuthRequiredError({ retryCommand: "instafy org list" });
9
10
  }
10
- const response = await fetch(`${controllerUrl}/orgs`, {
11
- headers: { authorization: `Bearer ${token}` },
11
+ const { response } = await fetchWithControllerAuth({
12
+ url: `${controllerUrl}/orgs`,
13
+ accessToken: resolved.token,
14
+ tokenSource: resolved.source,
15
+ profile: resolved.profile,
16
+ cwd: process.cwd(),
12
17
  });
13
18
  if (!response.ok) {
14
19
  const text = await response.text().catch(() => "");
package/dist/project.js CHANGED
@@ -2,20 +2,29 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import kleur from "kleur";
4
4
  import { stdin as input } from "node:process";
5
- import { getInstafyProfileConfigPath, resolveConfiguredStudioUrl, resolveControllerUrl, resolveUserAccessToken, } from "./config.js";
5
+ import { getInstafyProfileConfigPath, resolveConfiguredStudioUrl, resolveControllerUrl, resolveUserAccessTokenWithSource, } from "./config.js";
6
6
  import { formatAuthRejectedError, formatAuthRequiredError } from "./errors.js";
7
7
  import { findProjectManifest } from "./project-manifest.js";
8
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
8
9
  let promptsModule = null;
9
10
  async function loadPrompts() {
10
11
  promptsModule ?? (promptsModule = import("@clack/prompts"));
11
12
  return promptsModule;
12
13
  }
13
- async function fetchOrganizations(controllerUrl, token, retryCommand) {
14
- const response = await fetch(`${controllerUrl}/orgs`, {
15
- headers: {
16
- authorization: `Bearer ${token}`,
17
- },
14
+ async function controllerFetch(controllerUrl, auth, pathname, init) {
15
+ const { response, accessToken } = await fetchWithControllerAuth({
16
+ url: `${controllerUrl}${pathname}`,
17
+ init,
18
+ accessToken: auth.accessToken,
19
+ tokenSource: auth.tokenSource,
20
+ profile: auth.profile,
21
+ cwd: auth.cwd,
18
22
  });
23
+ auth.accessToken = accessToken;
24
+ return response;
25
+ }
26
+ async function fetchOrganizations(controllerUrl, auth, retryCommand) {
27
+ const response = await controllerFetch(controllerUrl, auth, "/orgs");
19
28
  if (!response.ok) {
20
29
  const text = await response.text().catch(() => "");
21
30
  if (response.status === 401 || response.status === 403) {
@@ -30,12 +39,8 @@ async function fetchOrganizations(controllerUrl, token, retryCommand) {
30
39
  const body = (await response.json());
31
40
  return Array.isArray(body.orgs) ? body.orgs : [];
32
41
  }
33
- async function fetchOrgProjects(controllerUrl, token, orgId, retryCommand) {
34
- const response = await fetch(`${controllerUrl}/orgs/${encodeURIComponent(orgId)}/projects`, {
35
- headers: {
36
- authorization: `Bearer ${token}`,
37
- },
38
- });
42
+ async function fetchOrgProjects(controllerUrl, auth, orgId, retryCommand) {
43
+ const response = await controllerFetch(controllerUrl, auth, `/orgs/${encodeURIComponent(orgId)}/projects`);
39
44
  if (!response.ok) {
40
45
  const text = await response.text().catch(() => "");
41
46
  if (response.status === 401 || response.status === 403) {
@@ -50,11 +55,10 @@ async function fetchOrgProjects(controllerUrl, token, orgId, retryCommand) {
50
55
  const body = (await response.json());
51
56
  return Array.isArray(body.projects) ? body.projects : [];
52
57
  }
53
- async function createOrganization(controllerUrl, token, payload, retryCommand) {
54
- const response = await fetch(`${controllerUrl}/orgs`, {
58
+ async function createOrganization(controllerUrl, auth, payload, retryCommand) {
59
+ const response = await controllerFetch(controllerUrl, auth, "/orgs", {
55
60
  method: "POST",
56
61
  headers: {
57
- authorization: `Bearer ${token}`,
58
62
  "content-type": "application/json",
59
63
  },
60
64
  body: JSON.stringify(payload),
@@ -77,7 +81,7 @@ async function createOrganization(controllerUrl, token, payload, retryCommand) {
77
81
  }
78
82
  return { orgId, orgName: json.org_name ?? json.orgName ?? null };
79
83
  }
80
- async function resolveOrg(controllerUrl, token, options, retryCommand) {
84
+ async function resolveOrg(controllerUrl, auth, options, retryCommand) {
81
85
  if (options.orgId) {
82
86
  return { orgId: options.orgId, orgName: options.orgName ?? null };
83
87
  }
@@ -112,18 +116,18 @@ async function resolveOrg(controllerUrl, token, options, retryCommand) {
112
116
  if (chosenSlug) {
113
117
  payload.orgSlug = chosenSlug;
114
118
  }
115
- const created = await createOrganization(controllerUrl, token, payload, retryCommand);
119
+ const created = await createOrganization(controllerUrl, auth, payload, retryCommand);
116
120
  return { orgId: created.orgId, orgName: created.orgName ?? chosenName };
117
121
  }
118
122
  if (orgSlug) {
119
- const orgs = await fetchOrganizations(controllerUrl, token, retryCommand);
123
+ const orgs = await fetchOrganizations(controllerUrl, auth, retryCommand);
120
124
  const matches = orgs.filter((org) => org.slug === orgSlug);
121
125
  if (matches.length === 1) {
122
126
  return { orgId: matches[0].id, orgName: matches[0].name ?? null };
123
127
  }
124
128
  }
125
129
  if (!orgSlug && !orgName) {
126
- const orgs = await fetchOrganizations(controllerUrl, token, retryCommand);
130
+ const orgs = await fetchOrganizations(controllerUrl, auth, retryCommand);
127
131
  if (orgs.length === 0) {
128
132
  if (allowInteractive) {
129
133
  const { confirm, isCancel } = await loadPrompts();
@@ -191,7 +195,7 @@ async function resolveOrg(controllerUrl, token, options, retryCommand) {
191
195
  if (options.ownerUserId) {
192
196
  payload.ownerUserId = options.ownerUserId;
193
197
  }
194
- const created = await createOrganization(controllerUrl, token, payload, retryCommand);
198
+ const created = await createOrganization(controllerUrl, auth, payload, retryCommand);
195
199
  return { orgId: created.orgId, orgName: created.orgName ?? chosenName };
196
200
  }
197
201
  throw new Error(`Organization slug "${orgSlug}" did not match an existing org, and org name is required to create one.\n\nNext:\n- Create an org in Studio: ${studioOrgUrl}\n- Or rerun: instafy project init --org-name \"My Org\" --org-slug "${orgSlug}"`);
@@ -209,17 +213,23 @@ async function resolveOrg(controllerUrl, token, options, retryCommand) {
209
213
  if (!payload.orgName) {
210
214
  throw new Error(`Organization name is required.\n\nNext:\n- Create an org in Studio: ${studioOrgUrl}\n- Or rerun: instafy project init --org-name \"My Org\"`);
211
215
  }
212
- const created = await createOrganization(controllerUrl, token, payload, retryCommand);
216
+ const created = await createOrganization(controllerUrl, auth, payload, retryCommand);
213
217
  return { orgId: created.orgId, orgName: created.orgName ?? orgName ?? null };
214
218
  }
215
219
  export async function listProjects(options) {
216
220
  const controllerUrl = resolveControllerUrl({ controllerUrl: options.controllerUrl ?? null });
217
- const token = resolveUserAccessToken({ accessToken: options.accessToken ?? null });
218
- if (!token) {
221
+ const resolved = resolveUserAccessTokenWithSource({ accessToken: options.accessToken ?? null });
222
+ if (!resolved.token) {
219
223
  throw formatAuthRequiredError({ retryCommand: "instafy project list" });
220
224
  }
221
225
  const retryCommand = "instafy project list";
222
- const orgs = await fetchOrganizations(controllerUrl, token, retryCommand);
226
+ const auth = {
227
+ accessToken: resolved.token,
228
+ tokenSource: resolved.source,
229
+ profile: resolved.profile,
230
+ cwd: process.cwd(),
231
+ };
232
+ const orgs = await fetchOrganizations(controllerUrl, auth, retryCommand);
223
233
  let targetOrgs = orgs;
224
234
  if (options.orgId) {
225
235
  targetOrgs = orgs.filter((org) => org.id === options.orgId);
@@ -235,7 +245,7 @@ export async function listProjects(options) {
235
245
  }
236
246
  const summaries = [];
237
247
  for (const org of targetOrgs) {
238
- const projects = await fetchOrgProjects(controllerUrl, token, org.id, retryCommand);
248
+ const projects = await fetchOrgProjects(controllerUrl, auth, org.id, retryCommand);
239
249
  summaries.push({ org, projects });
240
250
  }
241
251
  if (options.json) {
@@ -267,27 +277,32 @@ export async function projectInit(options) {
267
277
  profile: options.profile ?? null,
268
278
  cwd: rootDir,
269
279
  });
270
- const token = resolveUserAccessToken({
280
+ const resolved = resolveUserAccessTokenWithSource({
271
281
  accessToken: options.accessToken ?? null,
272
282
  profile: options.profile ?? null,
273
283
  cwd: rootDir,
274
284
  });
275
- if (!token) {
285
+ if (!resolved.token) {
276
286
  throw formatAuthRequiredError({
277
287
  retryCommand: "instafy project init",
278
288
  advancedHint: "pass --access-token or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN",
279
289
  });
280
290
  }
281
291
  const retryCommand = "instafy project init";
282
- const org = await resolveOrg(controllerUrl, token, options, retryCommand);
292
+ const auth = {
293
+ accessToken: resolved.token,
294
+ tokenSource: resolved.source,
295
+ profile: resolved.profile,
296
+ cwd: rootDir,
297
+ };
298
+ const org = await resolveOrg(controllerUrl, auth, options, retryCommand);
283
299
  const body = {
284
300
  projectType: options.projectType,
285
301
  ownerUserId: options.ownerUserId,
286
302
  };
287
- const response = await fetch(`${controllerUrl}/orgs/${encodeURIComponent(org.orgId)}/projects`, {
303
+ const response = await controllerFetch(controllerUrl, auth, `/orgs/${encodeURIComponent(org.orgId)}/projects`, {
288
304
  method: "POST",
289
305
  headers: {
290
- authorization: `Bearer ${token}`,
291
306
  "content-type": "application/json",
292
307
  },
293
308
  body: JSON.stringify(body),
package/dist/runtime.js CHANGED
@@ -6,9 +6,10 @@ import { fileURLToPath } from "node:url";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import os from "node:os";
8
8
  import { ensureRatholeBinary } from "./rathole.js";
9
- import { resolveConfiguredAccessToken } from "./config.js";
9
+ import { resolveActiveProfileName, resolveConfiguredAccessToken } from "./config.js";
10
10
  import { formatAuthRejectedError } from "./errors.js";
11
11
  import { findProjectManifest } from "./project-manifest.js";
12
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
12
13
  const INSTAFY_DIR = path.join(os.homedir(), ".instafy");
13
14
  const STATE_FILE = path.join(INSTAFY_DIR, "cli-runtime-state.json");
14
15
  const LOG_DIR = path.join(INSTAFY_DIR, "cli-runtime-logs");
@@ -188,13 +189,28 @@ function findRatholeOnPath() {
188
189
  }
189
190
  return null;
190
191
  }
191
- function resolveControllerAccessTokenForCli(options, env, supabaseAccessToken) {
192
- return (normalizeToken(options.controllerAccessToken) ??
193
- normalizeToken(env["INSTAFY_ACCESS_TOKEN"]) ??
194
- normalizeToken(env["CONTROLLER_ACCESS_TOKEN"]) ??
195
- supabaseAccessToken ??
196
- normalizeToken(env["SUPABASE_ACCESS_TOKEN"]) ??
197
- resolveConfiguredAccessToken());
192
+ function resolveControllerAccessTokenForCliWithSource(options, env, supabaseAccessToken, profile, cwd) {
193
+ const explicit = normalizeToken(options.controllerAccessToken);
194
+ if (explicit) {
195
+ return { token: explicit, source: "explicit" };
196
+ }
197
+ const envToken = normalizeToken(env["INSTAFY_ACCESS_TOKEN"]) ??
198
+ normalizeToken(env["CONTROLLER_ACCESS_TOKEN"]);
199
+ if (envToken) {
200
+ return { token: envToken, source: "env" };
201
+ }
202
+ if (supabaseAccessToken) {
203
+ return { token: supabaseAccessToken, source: "explicit" };
204
+ }
205
+ const supabaseEnvToken = normalizeToken(env["SUPABASE_ACCESS_TOKEN"]);
206
+ if (supabaseEnvToken) {
207
+ return { token: supabaseEnvToken, source: "env" };
208
+ }
209
+ const stored = resolveConfiguredAccessToken({ profile, cwd });
210
+ if (stored) {
211
+ return { token: stored, source: "config" };
212
+ }
213
+ return { token: null, source: "none" };
198
214
  }
199
215
  export async function resolveRatholeBinaryForCli(options) {
200
216
  const warn = options.warn ??
@@ -245,16 +261,23 @@ export async function resolveRatholeBinaryForCli(options) {
245
261
  export async function mintRuntimeAccessToken(params) {
246
262
  const url = params.controllerUrl.replace(/\/$/, "");
247
263
  const target = `${url}/projects/${encodeURIComponent(params.projectId)}/runtime/token`;
248
- const response = await fetch(target, {
249
- method: "POST",
250
- headers: {
251
- authorization: `Bearer ${params.controllerAccessToken}`,
252
- "content-type": "application/json",
264
+ const { response } = await fetchWithControllerAuth({
265
+ url: target,
266
+ init: {
267
+ method: "POST",
268
+ headers: {
269
+ "content-type": "application/json",
270
+ },
271
+ body: JSON.stringify({
272
+ runtimeId: params.runtimeId,
273
+ leaseId: params.leaseId,
274
+ scopes: params.scopes,
275
+ }),
253
276
  },
254
- body: JSON.stringify({
255
- runtimeId: params.runtimeId,
256
- scopes: params.scopes,
257
- }),
277
+ accessToken: params.controllerAccessToken,
278
+ tokenSource: params.tokenSource ?? "env",
279
+ profile: params.profile ?? null,
280
+ cwd: params.cwd ?? null,
258
281
  });
259
282
  if (!response.ok) {
260
283
  const text = await response.text().catch(() => "");
@@ -273,6 +296,55 @@ export async function mintRuntimeAccessToken(params) {
273
296
  }
274
297
  return token;
275
298
  }
299
+ async function requestRuntimeLease(params) {
300
+ const url = params.controllerUrl.replace(/\/$/, "");
301
+ const target = `${url}/projects/${encodeURIComponent(params.projectId)}/runtime/request`;
302
+ const { response, accessToken } = await fetchWithControllerAuth({
303
+ url: target,
304
+ init: {
305
+ method: "POST",
306
+ headers: {
307
+ "content-type": "application/json",
308
+ },
309
+ body: JSON.stringify({
310
+ provider: params.provider,
311
+ displayName: params.displayName,
312
+ idleTtlSeconds: params.idleTtlSeconds,
313
+ requestTunnel: params.requestTunnel,
314
+ runtimeId: params.runtimeId,
315
+ }),
316
+ },
317
+ accessToken: params.accessToken,
318
+ tokenSource: params.tokenSource,
319
+ profile: params.profile,
320
+ cwd: params.cwd,
321
+ });
322
+ if (!response.ok) {
323
+ const text = await response.text().catch(() => "");
324
+ if (response.status === 401 || response.status === 403) {
325
+ throw formatAuthRejectedError({
326
+ status: response.status,
327
+ responseBody: text,
328
+ });
329
+ }
330
+ throw new Error(`Instafy server rejected runtime request (${response.status} ${response.statusText}): ${text}`);
331
+ }
332
+ const payload = (await response.json());
333
+ const runtime = (payload["runtime"] ?? payload);
334
+ const runtimeId = (typeof runtime?.["runtimeId"] === "string" ? runtime["runtimeId"] : null) ??
335
+ (typeof runtime?.["runtime_id"] === "string" ? runtime["runtime_id"] : null) ??
336
+ null;
337
+ const leaseId = (typeof runtime?.["leaseId"] === "string" ? runtime["leaseId"] : null) ??
338
+ (typeof runtime?.["lease_id"] === "string" ? runtime["lease_id"] : null) ??
339
+ null;
340
+ if (!runtimeId || !runtimeId.trim()) {
341
+ throw new Error("Instafy server response missing runtimeId while requesting runtime lease.");
342
+ }
343
+ if (!leaseId || !leaseId.trim()) {
344
+ throw new Error("Instafy server response missing leaseId while requesting runtime lease.");
345
+ }
346
+ return { runtimeId: runtimeId.trim(), leaseId: leaseId.trim(), accessToken };
347
+ }
276
348
  function isProcessAlive(pid) {
277
349
  try {
278
350
  process.kill(pid, 0);
@@ -284,6 +356,7 @@ function isProcessAlive(pid) {
284
356
  }
285
357
  export async function runtimeStart(options) {
286
358
  const env = { ...process.env };
359
+ const cwd = process.cwd();
287
360
  const existing = readState();
288
361
  if (existing) {
289
362
  if (existing.runner === "docker" && existing.containerId && dockerContainerRunning(existing.containerId)) {
@@ -293,7 +366,7 @@ export async function runtimeStart(options) {
293
366
  throw new Error(`Runtime already running (pid ${existing.pid}) for project ${existing.projectId}. Stop it first.`);
294
367
  }
295
368
  }
296
- const manifestInfo = findProjectManifest(process.cwd());
369
+ const manifestInfo = findProjectManifest(cwd);
297
370
  const projectId = options.project ?? env["PROJECT_ID"] ?? manifestInfo.manifest?.projectId ?? null;
298
371
  if (!projectId) {
299
372
  throw new Error("No project configured. Run `instafy project init` in this folder (recommended) or pass --project.");
@@ -303,7 +376,10 @@ export async function runtimeStart(options) {
303
376
  if (supabaseAccessToken) {
304
377
  env["SUPABASE_ACCESS_TOKEN"] = supabaseAccessToken;
305
378
  }
306
- let controllerAccessToken = resolveControllerAccessTokenForCli(options, env, supabaseAccessToken);
379
+ const profile = resolveActiveProfileName({ cwd });
380
+ const controllerAccessTokenResult = resolveControllerAccessTokenForCliWithSource(options, env, supabaseAccessToken, profile, cwd);
381
+ let controllerAccessToken = controllerAccessTokenResult.token;
382
+ const controllerAccessTokenSource = controllerAccessTokenResult.source;
307
383
  let runtimeAccessToken = normalizeToken(options.runtimeToken) ?? normalizeToken(env["RUNTIME_ACCESS_TOKEN"]);
308
384
  const agentKey = options.controllerToken ??
309
385
  env["INSTAFY_SERVICE_TOKEN"] ??
@@ -324,16 +400,6 @@ export async function runtimeStart(options) {
324
400
  env["CONTROLLER_BASE_URL"] ??
325
401
  manifestInfo.manifest?.controllerUrl ??
326
402
  "http://127.0.0.1:8788";
327
- if (!runtimeAccessToken && controllerAccessToken) {
328
- runtimeAccessToken = await mintRuntimeAccessToken({
329
- controllerUrl: env["CONTROLLER_BASE_URL"],
330
- controllerAccessToken,
331
- projectId,
332
- });
333
- }
334
- if (runtimeAccessToken) {
335
- env["RUNTIME_ACCESS_TOKEN"] = runtimeAccessToken;
336
- }
337
403
  if (options.codexBin)
338
404
  env["CODEX_BIN"] = options.codexBin;
339
405
  if (options.proxyBaseUrl)
@@ -352,22 +418,6 @@ export async function runtimeStart(options) {
352
418
  if (options.originEndpoint && options.originEndpoint.trim()) {
353
419
  env["ORIGIN_ENDPOINT"] = options.originEndpoint.trim();
354
420
  }
355
- let originToken = normalizeToken(options.originToken) ?? normalizeToken(env["ORIGIN_INTERNAL_TOKEN"]);
356
- if (!originToken && runtimeAccessToken) {
357
- originToken = runtimeAccessToken;
358
- }
359
- if (!originToken && controllerAccessToken) {
360
- originToken = await mintRuntimeAccessToken({
361
- controllerUrl: env["CONTROLLER_BASE_URL"],
362
- controllerAccessToken,
363
- projectId,
364
- runtimeId: env["RUNTIME_ID"],
365
- });
366
- }
367
- if (!originToken) {
368
- throw new Error("Runtime/origin token is required (--origin-token or ORIGIN_INTERNAL_TOKEN), or provide --runtime-token/--controller-access-token to mint/use one");
369
- }
370
- env["ORIGIN_INTERNAL_TOKEN"] = originToken;
371
421
  const skipAuthRaw = env["ORIGIN_SKIP_AUTH"]?.trim().toLowerCase();
372
422
  const skipAuth = skipAuthRaw === "1" || skipAuthRaw === "true" || skipAuthRaw === "yes";
373
423
  const usesTunnel = !env["ORIGIN_ENDPOINT"];
@@ -378,12 +428,10 @@ export async function runtimeStart(options) {
378
428
  }
379
429
  env["RUNTIME_DISPLAY_NAME"] =
380
430
  options.displayName ?? env["RUNTIME_DISPLAY_NAME"] ?? "Instafy CLI Runtime";
381
- const manifestProvider = manifestInfo.manifest?.orgName?.trim() ||
382
- manifestInfo.manifest?.orgId?.toString().trim();
383
431
  env["RUNTIME_PROVIDER"] =
384
- options.provider ?? env["RUNTIME_PROVIDER"] ?? manifestProvider ?? "self-hosted";
432
+ options.provider ?? env["RUNTIME_PROVIDER"] ?? "self-hosted";
385
433
  env["RUNTIME_VERSION"] = env["RUNTIME_VERSION"] ?? "0.1.0";
386
- env["RUNTIME_REQUIRE_CODEX_BIN"] = env["RUNTIME_REQUIRE_CODEX_BIN"] || "1";
434
+ env["RUNTIME_REQUIRE_CODEX_BIN"] = env["RUNTIME_REQUIRE_CODEX_BIN"] || "0";
387
435
  env["RUNTIME_CAPABILITIES"] =
388
436
  env["RUNTIME_CAPABILITIES"] ||
389
437
  JSON.stringify({ fs: true, github: true, supabase: true, runs: true, agent: true, origin: true });
@@ -397,6 +445,90 @@ export async function runtimeStart(options) {
397
445
  const runtimeBin = runtimeMode === "docker" ? null : tryResolveRuntimeBinary();
398
446
  const useDocker = runtimeMode === "docker" ||
399
447
  (runtimeMode === "auto" && !runtimeBin);
448
+ if (useDocker && !isDockerAvailable()) {
449
+ throw new Error("runtime-agent is not available (no local binary and docker is not installed). Install Docker Desktop, or build runtime-agent from source.");
450
+ }
451
+ const originEnabledRaw = env["ORIGIN_ENABLED"]?.trim().toLowerCase();
452
+ const originEnabled = !originEnabledRaw ||
453
+ originEnabledRaw === "1" ||
454
+ originEnabledRaw === "true" ||
455
+ originEnabledRaw === "yes" ||
456
+ originEnabledRaw === "on";
457
+ if (originEnabled && usesTunnel && !useDocker) {
458
+ const ratholeResolved = await resolveRatholeBinaryForCli({
459
+ env,
460
+ version: process.env["RATHOLE_VERSION"] ?? null,
461
+ cacheDir: process.env["RATHOLE_CACHE_DIR"] ?? null,
462
+ logger: (message) => console.log(kleur.cyan(`[rathole] ${message}`)),
463
+ warn: (message) => console.warn(kleur.yellow(message)),
464
+ });
465
+ if (!ratholeResolved) {
466
+ throw new Error("Tunnel is required but rathole is unavailable. Set RATHOLE_BIN (recommended) or pass --origin-endpoint to use a reachable URL.");
467
+ }
468
+ }
469
+ if (originEnabled && usesTunnel && !env["RUNTIME_LEASE_ID"]) {
470
+ const bearer = controllerAccessToken ?? runtimeAccessToken;
471
+ const tokenSource = controllerAccessToken ? controllerAccessTokenSource : "env";
472
+ if (!bearer) {
473
+ throw new Error("Login required to request a tunnel-enabled runtime lease. Run `instafy login` or pass --controller-access-token/--runtime-token.");
474
+ }
475
+ const ttl = typeof env["RUNTIME_LEASE_SECONDS"] === "string" ? Number(env["RUNTIME_LEASE_SECONDS"]) : NaN;
476
+ const idleTtlSeconds = Number.isFinite(ttl) && ttl > 0 ? ttl : undefined;
477
+ const lease = await requestRuntimeLease({
478
+ controllerUrl: env["CONTROLLER_BASE_URL"],
479
+ accessToken: bearer,
480
+ tokenSource,
481
+ profile,
482
+ cwd,
483
+ projectId,
484
+ provider: env["RUNTIME_PROVIDER"],
485
+ displayName: env["RUNTIME_DISPLAY_NAME"],
486
+ idleTtlSeconds,
487
+ requestTunnel: false,
488
+ runtimeId: env["RUNTIME_ID"],
489
+ });
490
+ env["RUNTIME_ID"] = lease.runtimeId;
491
+ env["RUNTIME_LEASE_ID"] = lease.leaseId;
492
+ if (controllerAccessToken && bearer === controllerAccessToken) {
493
+ controllerAccessToken = lease.accessToken;
494
+ env["CONTROLLER_ACCESS_TOKEN"] = controllerAccessToken;
495
+ }
496
+ }
497
+ if (!runtimeAccessToken && controllerAccessToken) {
498
+ runtimeAccessToken = await mintRuntimeAccessToken({
499
+ controllerUrl: env["CONTROLLER_BASE_URL"],
500
+ controllerAccessToken,
501
+ projectId,
502
+ runtimeId: env["RUNTIME_ID"],
503
+ leaseId: env["RUNTIME_LEASE_ID"],
504
+ tokenSource: controllerAccessTokenSource,
505
+ profile,
506
+ cwd,
507
+ });
508
+ }
509
+ if (runtimeAccessToken) {
510
+ env["RUNTIME_ACCESS_TOKEN"] = runtimeAccessToken;
511
+ }
512
+ let originToken = normalizeToken(options.originToken) ?? normalizeToken(env["ORIGIN_INTERNAL_TOKEN"]);
513
+ if (!originToken && runtimeAccessToken) {
514
+ originToken = runtimeAccessToken;
515
+ }
516
+ if (!originToken && controllerAccessToken) {
517
+ originToken = await mintRuntimeAccessToken({
518
+ controllerUrl: env["CONTROLLER_BASE_URL"],
519
+ controllerAccessToken,
520
+ projectId,
521
+ runtimeId: env["RUNTIME_ID"],
522
+ leaseId: env["RUNTIME_LEASE_ID"],
523
+ tokenSource: controllerAccessTokenSource,
524
+ profile,
525
+ cwd,
526
+ });
527
+ }
528
+ if (!originToken) {
529
+ throw new Error("Runtime/origin token is required (--origin-token or ORIGIN_INTERNAL_TOKEN), or provide --runtime-token/--controller-access-token to mint/use one");
530
+ }
531
+ env["ORIGIN_INTERNAL_TOKEN"] = originToken;
400
532
  if (!useDocker) {
401
533
  if (!env["ORIGIN_ENDPOINT"]) {
402
534
  const ratholeResolved = await resolveRatholeBinaryForCli({
@@ -461,9 +593,6 @@ export async function runtimeStart(options) {
461
593
  });
462
594
  return;
463
595
  }
464
- if (!isDockerAvailable()) {
465
- throw new Error("runtime-agent is not available (no local binary and docker is not installed). Install Docker Desktop, or build runtime-agent from source.");
466
- }
467
596
  const image = resolveRuntimeAgentImage();
468
597
  const containerName = `instafy-runtime-${originId}`;
469
598
  const hostPort = String(options.bindPort ?? env["ORIGIN_BIND_PORT"] ?? 54332);
@@ -471,6 +600,9 @@ export async function runtimeStart(options) {
471
600
  const dockerEnv = { ...env };
472
601
  dockerEnv["WORKSPACE_DIR"] = "/workspace";
473
602
  dockerEnv["CODEX_HOME"] = "/workspace/.codex";
603
+ dockerEnv["TMPDIR"] = "/tmp";
604
+ dockerEnv["TMP"] = "/tmp";
605
+ dockerEnv["TEMP"] = "/tmp";
474
606
  dockerEnv["ORIGIN_BIND_HOST"] = "0.0.0.0";
475
607
  dockerEnv["CONTROLLER_BASE_URL"] = normalizeControllerUrlForDocker(env["CONTROLLER_BASE_URL"] ?? "");
476
608
  const envArgs = [];
@@ -500,7 +632,14 @@ export async function runtimeStart(options) {
500
632
  const started = spawnSync("docker", runArgs, { encoding: "utf8" });
501
633
  if (started.status !== 0) {
502
634
  const stderr = String(started.stderr ?? "").trim();
503
- throw new Error(`docker run failed: ${stderr || "unknown error"}`);
635
+ const normalized = (stderr || "").toLowerCase();
636
+ const hints = [];
637
+ if (image.startsWith("ghcr.io/") && (normalized.includes("not found") || normalized.includes("denied") || normalized.includes("unauthorized") || normalized.includes("manifest unknown"))) {
638
+ hints.push("If this is a GHCR image, ensure it's published and you are authenticated (`docker login ghcr.io`).");
639
+ }
640
+ hints.push("Override the image with `INSTAFY_RUNTIME_AGENT_IMAGE=... instafy runtime start`.");
641
+ const suffix = hints.length > 0 ? `\n\nNext:\n- ${hints.join("\n- ")}` : "";
642
+ throw new Error(`docker run failed: ${stderr || "unknown error"}${suffix}`);
504
643
  }
505
644
  const containerId = String(started.stdout ?? "").trim();
506
645
  if (!containerId) {
@@ -740,15 +879,27 @@ export async function runtimeStop(options) {
740
879
  }
741
880
  }
742
881
  export async function runtimeToken(options) {
882
+ const cwd = process.cwd();
743
883
  const controllerUrl = options.controllerUrl ??
744
884
  process.env["INSTAFY_SERVER_URL"] ??
745
885
  process.env["CONTROLLER_BASE_URL"] ??
746
886
  "http://127.0.0.1:8788";
887
+ const profile = resolveActiveProfileName({ cwd });
888
+ const stored = resolveConfiguredAccessToken({ profile, cwd });
889
+ const tokenSource = options.controllerAccessToken
890
+ ? "explicit"
891
+ : process.env["INSTAFY_ACCESS_TOKEN"] ||
892
+ process.env["CONTROLLER_ACCESS_TOKEN"] ||
893
+ process.env["SUPABASE_ACCESS_TOKEN"]
894
+ ? "env"
895
+ : stored
896
+ ? "config"
897
+ : "none";
747
898
  const token = options.controllerAccessToken ??
748
899
  process.env["INSTAFY_ACCESS_TOKEN"] ??
749
900
  process.env["CONTROLLER_ACCESS_TOKEN"] ??
750
901
  process.env["SUPABASE_ACCESS_TOKEN"] ??
751
- resolveConfiguredAccessToken();
902
+ stored;
752
903
  if (!token) {
753
904
  throw new Error("Login required. Run `instafy login` or pass --access-token / set SUPABASE_ACCESS_TOKEN.");
754
905
  }
@@ -758,6 +909,9 @@ export async function runtimeToken(options) {
758
909
  projectId: options.project,
759
910
  runtimeId: options.runtimeId,
760
911
  scopes: options.scopes,
912
+ tokenSource: tokenSource === "none" ? "env" : tokenSource,
913
+ profile,
914
+ cwd,
761
915
  });
762
916
  if (options.json) {
763
917
  console.log(JSON.stringify({ token: minted }));
@@ -0,0 +1,89 @@
1
+ import { readInstafyCliConfig, readInstafyProfileConfig, resolveActiveProfileName, writeInstafyCliConfig, writeInstafyProfileConfig, } from "./config.js";
2
+ function normalizeToken(value) {
3
+ const trimmed = typeof value === "string" ? value.trim() : "";
4
+ if (!trimmed)
5
+ return null;
6
+ const lowered = trimmed.toLowerCase();
7
+ if (lowered === "null" || lowered === "undefined")
8
+ return null;
9
+ return trimmed;
10
+ }
11
+ function normalizeUrl(value) {
12
+ const trimmed = typeof value === "string" ? value.trim() : "";
13
+ if (!trimmed)
14
+ return null;
15
+ return trimmed.replace(/\/$/, "");
16
+ }
17
+ function resolveSupabaseUrl(configured) {
18
+ return (normalizeUrl(configured) ??
19
+ normalizeUrl(process.env["SUPABASE_URL"]) ??
20
+ normalizeUrl(process.env["VITE_SUPABASE_URL"]) ??
21
+ normalizeUrl(process.env["SUPABASE_PROJECT_URL"]) ??
22
+ null);
23
+ }
24
+ function resolveSupabaseAnonKey(configured) {
25
+ return (normalizeToken(configured) ??
26
+ normalizeToken(process.env["SUPABASE_ANON_KEY"]) ??
27
+ normalizeToken(process.env["VITE_SUPABASE_ANON_KEY"]) ??
28
+ null);
29
+ }
30
+ async function refreshSupabaseSession(params) {
31
+ const response = await fetch(`${params.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
32
+ method: "POST",
33
+ headers: {
34
+ apikey: params.supabaseAnonKey,
35
+ authorization: `Bearer ${params.supabaseAnonKey}`,
36
+ "content-type": "application/json",
37
+ },
38
+ body: JSON.stringify({ refresh_token: params.refreshToken }),
39
+ });
40
+ if (!response.ok) {
41
+ return null;
42
+ }
43
+ const body = (await response.json());
44
+ const accessToken = typeof body["access_token"] === "string" ? body["access_token"].trim() : "";
45
+ if (!accessToken) {
46
+ return null;
47
+ }
48
+ const refreshToken = typeof body["refresh_token"] === "string" ? body["refresh_token"].trim() : null;
49
+ return {
50
+ accessToken,
51
+ refreshToken,
52
+ supabaseUrl: params.supabaseUrl,
53
+ supabaseAnonKey: params.supabaseAnonKey,
54
+ };
55
+ }
56
+ export async function refreshStoredSupabaseSession(params) {
57
+ const profile = resolveActiveProfileName(params);
58
+ const config = profile ? readInstafyProfileConfig(profile) : readInstafyCliConfig();
59
+ const refreshToken = normalizeToken(config.refreshToken ?? null);
60
+ if (!refreshToken) {
61
+ return null;
62
+ }
63
+ const supabaseUrl = resolveSupabaseUrl(config.supabaseUrl ?? null);
64
+ const supabaseAnonKey = resolveSupabaseAnonKey(config.supabaseAnonKey ?? null);
65
+ if (!supabaseUrl || !supabaseAnonKey) {
66
+ return null;
67
+ }
68
+ const refreshed = await refreshSupabaseSession({
69
+ supabaseUrl,
70
+ supabaseAnonKey,
71
+ refreshToken,
72
+ });
73
+ if (!refreshed) {
74
+ return null;
75
+ }
76
+ const next = {
77
+ accessToken: refreshed.accessToken,
78
+ refreshToken: refreshed.refreshToken ?? refreshToken,
79
+ supabaseUrl: refreshed.supabaseUrl,
80
+ supabaseAnonKey: refreshed.supabaseAnonKey,
81
+ };
82
+ if (profile) {
83
+ writeInstafyProfileConfig(profile, next);
84
+ }
85
+ else {
86
+ writeInstafyCliConfig(next);
87
+ }
88
+ return refreshed;
89
+ }
package/dist/tunnel.js CHANGED
@@ -5,8 +5,9 @@ import { spawn } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import kleur from "kleur";
7
7
  import { findProjectManifest, resolveRatholeBinaryForCli } from "./runtime.js";
8
- import { resolveConfiguredControllerUrl, resolveControllerUrl as resolveDefaultControllerUrl, resolveUserAccessToken, } from "./config.js";
8
+ import { resolveConfiguredControllerUrl, resolveControllerUrl as resolveDefaultControllerUrl, resolveUserAccessTokenWithSource, } from "./config.js";
9
9
  import { formatAuthRejectedError, formatAuthRequiredError } from "./errors.js";
10
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
10
11
  const INSTAFY_DIR = path.join(os.homedir(), ".instafy");
11
12
  const TUNNEL_STATE_FILE = path.join(INSTAFY_DIR, "cli-tunnel-state.json");
12
13
  const TUNNEL_LOG_DIR = path.join(INSTAFY_DIR, "cli-tunnel-logs");
@@ -56,11 +57,11 @@ function resolveTunnelControllerUrl(opts) {
56
57
  function resolveControllerToken(opts, retryCommand = "instafy tunnel start") {
57
58
  const explicit = opts.controllerToken?.trim();
58
59
  if (explicit) {
59
- return explicit;
60
+ return { token: explicit, source: "explicit", profile: null };
60
61
  }
61
- const accessToken = resolveUserAccessToken();
62
- if (accessToken) {
63
- return accessToken;
62
+ const resolved = resolveUserAccessTokenWithSource();
63
+ if (resolved.token) {
64
+ return { token: resolved.token, source: resolved.source, profile: resolved.profile };
64
65
  }
65
66
  const serviceToken = readEnv("INSTAFY_SERVICE_TOKEN") ||
66
67
  readEnv("CONTROLLER_BEARER") ||
@@ -71,7 +72,7 @@ function resolveControllerToken(opts, retryCommand = "instafy tunnel start") {
71
72
  readEnv("CONTROLLER_INTERNAL_TOKEN") ||
72
73
  null;
73
74
  if (serviceToken) {
74
- return serviceToken;
75
+ return { token: serviceToken, source: "env", profile: null };
75
76
  }
76
77
  throw formatAuthRequiredError({
77
78
  retryCommand,
@@ -156,16 +157,22 @@ function removeTunnelState(tunnelId) {
156
157
  writeStateFile({ version: 1, tunnels: next });
157
158
  return existing;
158
159
  }
159
- async function requestTunnel(controllerUrl, token, projectId, metadata) {
160
+ async function requestTunnel(controllerUrl, auth, projectId, metadata) {
160
161
  const target = `${cleanUrl(controllerUrl)}/projects/${encodeURIComponent(projectId)}/tunnels/request`;
161
- const response = await fetch(target, {
162
- method: "POST",
163
- headers: {
164
- authorization: `Bearer ${token}`,
165
- "content-type": "application/json",
166
- accept: "application/json",
162
+ const { response } = await fetchWithControllerAuth({
163
+ url: target,
164
+ init: {
165
+ method: "POST",
166
+ headers: {
167
+ "content-type": "application/json",
168
+ accept: "application/json",
169
+ },
170
+ body: JSON.stringify({ metadata }),
167
171
  },
168
- body: JSON.stringify({ metadata }),
172
+ accessToken: auth.token,
173
+ tokenSource: auth.source,
174
+ profile: auth.profile,
175
+ cwd: process.cwd(),
169
176
  });
170
177
  if (!response.ok) {
171
178
  const text = await response.text().catch(() => "");
@@ -262,7 +269,7 @@ export async function startTunnelSession(opts) {
262
269
  return;
263
270
  cleanedUp = true;
264
271
  child.kill("SIGTERM");
265
- await revokeTunnel(controllerUrl, controllerToken, projectId, grant.tunnelId);
272
+ await revokeTunnel(controllerUrl, controllerToken.token, projectId, grant.tunnelId);
266
273
  fs.rmSync(workdir, { recursive: true, force: true });
267
274
  };
268
275
  child.on("exit", () => {
@@ -413,7 +420,7 @@ export async function stopTunnelSession(opts) {
413
420
  }
414
421
  }
415
422
  const controllerToken = resolveControllerToken(opts, `instafy tunnel stop ${tunnelId}`);
416
- await revokeTunnel(entry.controllerUrl, controllerToken, entry.projectId, entry.tunnelId);
423
+ await revokeTunnel(entry.controllerUrl, controllerToken.token, entry.projectId, entry.tunnelId);
417
424
  try {
418
425
  fs.rmSync(entry.workdir, { recursive: true, force: true });
419
426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instafy/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Run Instafy projects locally, link folders to Studio, and share previews/webhooks via tunnels.",
5
5
  "private": false,
6
6
  "publishConfig": {