@instafy/cli 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/project.js CHANGED
@@ -1,76 +1,235 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import kleur from "kleur";
4
- import { resolveControllerUrl, resolveUserAccessToken } from "./config.js";
5
- function formatAuthRequiredError() {
6
- return new Error("Login required. Run `instafy login` (recommended) or pass --access-token / set SUPABASE_ACCESS_TOKEN.");
4
+ import { stdin as input } from "node:process";
5
+ import { getInstafyProfileConfigPath, resolveConfiguredStudioUrl, resolveControllerUrl, resolveUserAccessTokenWithSource, } from "./config.js";
6
+ import { formatAuthRejectedError, formatAuthRequiredError } from "./errors.js";
7
+ import { findProjectManifest } from "./project-manifest.js";
8
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
9
+ let promptsModule = null;
10
+ async function loadPrompts() {
11
+ promptsModule ?? (promptsModule = import("@clack/prompts"));
12
+ return promptsModule;
7
13
  }
8
- async function fetchOrganizations(controllerUrl, token) {
9
- const response = await fetch(`${controllerUrl}/orgs`, {
10
- headers: {
11
- authorization: `Bearer ${token}`,
12
- },
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,
13
22
  });
23
+ auth.accessToken = accessToken;
24
+ return response;
25
+ }
26
+ async function fetchOrganizations(controllerUrl, auth, retryCommand) {
27
+ const response = await controllerFetch(controllerUrl, auth, "/orgs");
14
28
  if (!response.ok) {
15
29
  const text = await response.text().catch(() => "");
30
+ if (response.status === 401 || response.status === 403) {
31
+ throw formatAuthRejectedError({
32
+ status: response.status,
33
+ responseBody: text,
34
+ retryCommand,
35
+ });
36
+ }
16
37
  throw new Error(`Organization list failed (${response.status} ${response.statusText}): ${text}`);
17
38
  }
18
39
  const body = (await response.json());
19
40
  return Array.isArray(body.orgs) ? body.orgs : [];
20
41
  }
21
- async function fetchOrgProjects(controllerUrl, token, orgId) {
22
- const response = await fetch(`${controllerUrl}/orgs/${encodeURIComponent(orgId)}/projects`, {
23
- headers: {
24
- authorization: `Bearer ${token}`,
25
- },
26
- });
42
+ async function fetchOrgProjects(controllerUrl, auth, orgId, retryCommand) {
43
+ const response = await controllerFetch(controllerUrl, auth, `/orgs/${encodeURIComponent(orgId)}/projects`);
27
44
  if (!response.ok) {
28
45
  const text = await response.text().catch(() => "");
46
+ if (response.status === 401 || response.status === 403) {
47
+ throw formatAuthRejectedError({
48
+ status: response.status,
49
+ responseBody: text,
50
+ retryCommand,
51
+ });
52
+ }
29
53
  throw new Error(`Project list failed (${response.status} ${response.statusText}): ${text}`);
30
54
  }
31
55
  const body = (await response.json());
32
56
  return Array.isArray(body.projects) ? body.projects : [];
33
57
  }
34
- async function resolveOrg(controllerUrl, token, options) {
35
- if (options.orgId) {
36
- return { orgId: options.orgId, orgName: options.orgName ?? null };
37
- }
38
- const payload = {};
39
- if (options.orgName) {
40
- payload.orgName = options.orgName;
41
- }
42
- if (options.orgSlug) {
43
- payload.orgSlug = options.orgSlug;
44
- }
45
- if (options.ownerUserId) {
46
- payload.ownerUserId = options.ownerUserId;
47
- }
48
- const response = await fetch(`${controllerUrl}/orgs`, {
58
+ async function createOrganization(controllerUrl, auth, payload, retryCommand) {
59
+ const response = await controllerFetch(controllerUrl, auth, "/orgs", {
49
60
  method: "POST",
50
61
  headers: {
51
- authorization: `Bearer ${token}`,
52
62
  "content-type": "application/json",
53
63
  },
54
64
  body: JSON.stringify(payload),
55
65
  });
56
66
  if (!response.ok) {
57
67
  const text = await response.text().catch(() => "");
68
+ if (response.status === 401 || response.status === 403) {
69
+ throw formatAuthRejectedError({
70
+ status: response.status,
71
+ responseBody: text,
72
+ retryCommand,
73
+ });
74
+ }
58
75
  throw new Error(`Organization creation failed (${response.status} ${response.statusText}): ${text}`);
59
76
  }
60
77
  const json = (await response.json());
61
- const orgId = json.org_id;
78
+ const orgId = json.org_id ?? json.orgId ?? json.id;
62
79
  if (!orgId) {
63
- throw new Error("Organization creation response missing org_id");
80
+ throw new Error(`Organization creation response missing org id (expected org_id/orgId/id). Raw: ${JSON.stringify(json)}`);
81
+ }
82
+ return { orgId, orgName: json.org_name ?? json.orgName ?? null };
83
+ }
84
+ async function resolveOrg(controllerUrl, auth, options, retryCommand) {
85
+ if (options.orgId) {
86
+ return { orgId: options.orgId, orgName: options.orgName ?? null };
87
+ }
88
+ const orgSlug = options.orgSlug?.trim() || null;
89
+ const orgName = options.orgName?.trim() || null;
90
+ const studioUrl = resolveConfiguredStudioUrl({ profile: options.profile ?? null }) ?? "https://staging.instafy.dev";
91
+ const studioOrgUrl = `${studioUrl.replace(/\/$/, "")}/studio?panel=settings`;
92
+ const allowInteractive = Boolean(input.isTTY && process.stdout.isTTY && options.json !== true && process.env.CI !== "true");
93
+ async function promptAndCreateOrg() {
94
+ const { isCancel, text } = await loadPrompts();
95
+ const enteredName = await text({
96
+ message: "Organization name",
97
+ defaultValue: "Personal",
98
+ });
99
+ if (isCancel(enteredName)) {
100
+ throw new Error("Cancelled.");
101
+ }
102
+ const chosenName = String(enteredName).trim() || "Personal";
103
+ const enteredSlug = await text({
104
+ message: "Organization slug (optional)",
105
+ });
106
+ if (isCancel(enteredSlug)) {
107
+ throw new Error("Cancelled.");
108
+ }
109
+ const chosenSlug = String(enteredSlug).trim();
110
+ const payload = {
111
+ orgName: chosenName,
112
+ };
113
+ if (options.ownerUserId) {
114
+ payload.ownerUserId = options.ownerUserId;
115
+ }
116
+ if (chosenSlug) {
117
+ payload.orgSlug = chosenSlug;
118
+ }
119
+ const created = await createOrganization(controllerUrl, auth, payload, retryCommand);
120
+ return { orgId: created.orgId, orgName: created.orgName ?? chosenName };
121
+ }
122
+ if (orgSlug) {
123
+ const orgs = await fetchOrganizations(controllerUrl, auth, retryCommand);
124
+ const matches = orgs.filter((org) => org.slug === orgSlug);
125
+ if (matches.length === 1) {
126
+ return { orgId: matches[0].id, orgName: matches[0].name ?? null };
127
+ }
128
+ }
129
+ if (!orgSlug && !orgName) {
130
+ const orgs = await fetchOrganizations(controllerUrl, auth, retryCommand);
131
+ if (orgs.length === 0) {
132
+ if (allowInteractive) {
133
+ const { confirm, isCancel } = await loadPrompts();
134
+ console.log(kleur.yellow("No organizations found for this account."));
135
+ console.log("");
136
+ console.log(`Create one in Studio: ${kleur.cyan(studioOrgUrl)}`);
137
+ console.log("");
138
+ const shouldCreate = await confirm({
139
+ message: "Create a new organization now?",
140
+ initialValue: true,
141
+ });
142
+ if (isCancel(shouldCreate) || !shouldCreate) {
143
+ throw new Error("No organization selected.");
144
+ }
145
+ return await promptAndCreateOrg();
146
+ }
147
+ throw new Error(`No organizations found.\n\nNext:\n- Create an org in Studio: ${studioOrgUrl}\n- Or rerun: instafy project init --org-name \"My Org\"`);
148
+ }
149
+ if (orgs.length === 1) {
150
+ return { orgId: orgs[0].id, orgName: orgs[0].name ?? null };
151
+ }
152
+ if (allowInteractive) {
153
+ const { isCancel, select } = await loadPrompts();
154
+ const selection = await select({
155
+ message: "Choose an organization for this project",
156
+ options: [
157
+ { value: "__create__", label: "+ Create a new organization" },
158
+ ...orgs.map((org) => ({
159
+ value: org.id,
160
+ label: org.name,
161
+ hint: `${org.slug ? `${org.slug} · ` : ""}${org.id}`,
162
+ })),
163
+ ],
164
+ initialValue: orgs[0].id,
165
+ });
166
+ if (isCancel(selection)) {
167
+ throw new Error("Cancelled.");
168
+ }
169
+ if (selection === "__create__") {
170
+ return await promptAndCreateOrg();
171
+ }
172
+ const pickedOrg = orgs.find((org) => org.id === selection);
173
+ if (!pickedOrg) {
174
+ throw new Error("Selected organization not found.");
175
+ }
176
+ return { orgId: pickedOrg.id, orgName: pickedOrg.name ?? null };
177
+ }
178
+ throw new Error("Multiple organizations found.\n\nNext:\n- instafy org list\n- instafy project init --org-id <uuid>");
179
+ }
180
+ if (orgSlug && !orgName) {
181
+ if (allowInteractive) {
182
+ const { isCancel, text } = await loadPrompts();
183
+ console.log(kleur.yellow("Organization slug did not match an existing org."));
184
+ console.log(`Create one in Studio: ${kleur.cyan(studioOrgUrl)}`);
185
+ console.log("");
186
+ const enteredName = await text({
187
+ message: "Organization name",
188
+ defaultValue: "Personal",
189
+ });
190
+ if (isCancel(enteredName)) {
191
+ throw new Error("Cancelled.");
192
+ }
193
+ const chosenName = String(enteredName).trim() || "Personal";
194
+ const payload = { orgName: chosenName, orgSlug };
195
+ if (options.ownerUserId) {
196
+ payload.ownerUserId = options.ownerUserId;
197
+ }
198
+ const created = await createOrganization(controllerUrl, auth, payload, retryCommand);
199
+ return { orgId: created.orgId, orgName: created.orgName ?? chosenName };
200
+ }
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}"`);
202
+ }
203
+ const payload = {};
204
+ if (orgName) {
205
+ payload.orgName = orgName;
206
+ }
207
+ if (orgSlug) {
208
+ payload.orgSlug = orgSlug;
209
+ }
210
+ if (options.ownerUserId) {
211
+ payload.ownerUserId = options.ownerUserId;
212
+ }
213
+ if (!payload.orgName) {
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\"`);
64
215
  }
65
- return { orgId, orgName: json.org_name ?? null };
216
+ const created = await createOrganization(controllerUrl, auth, payload, retryCommand);
217
+ return { orgId: created.orgId, orgName: created.orgName ?? orgName ?? null };
66
218
  }
67
219
  export async function listProjects(options) {
68
220
  const controllerUrl = resolveControllerUrl({ controllerUrl: options.controllerUrl ?? null });
69
- const token = resolveUserAccessToken({ accessToken: options.accessToken ?? null });
70
- if (!token) {
71
- throw formatAuthRequiredError();
221
+ const resolved = resolveUserAccessTokenWithSource({ accessToken: options.accessToken ?? null });
222
+ if (!resolved.token) {
223
+ throw formatAuthRequiredError({ retryCommand: "instafy project list" });
72
224
  }
73
- const orgs = await fetchOrganizations(controllerUrl, token);
225
+ const retryCommand = "instafy project list";
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);
74
233
  let targetOrgs = orgs;
75
234
  if (options.orgId) {
76
235
  targetOrgs = orgs.filter((org) => org.id === options.orgId);
@@ -86,7 +245,7 @@ export async function listProjects(options) {
86
245
  }
87
246
  const summaries = [];
88
247
  for (const org of targetOrgs) {
89
- const projects = await fetchOrgProjects(controllerUrl, token, org.id);
248
+ const projects = await fetchOrgProjects(controllerUrl, auth, org.id, retryCommand);
90
249
  summaries.push({ org, projects });
91
250
  }
92
251
  if (options.json) {
@@ -113,29 +272,65 @@ export async function listProjects(options) {
113
272
  }
114
273
  export async function projectInit(options) {
115
274
  const rootDir = path.resolve(options.path ?? process.cwd());
116
- const controllerUrl = resolveControllerUrl({ controllerUrl: options.controllerUrl ?? null });
117
- const token = resolveUserAccessToken({ accessToken: options.accessToken ?? null });
118
- if (!token) {
119
- throw formatAuthRequiredError();
275
+ const controllerUrl = resolveControllerUrl({
276
+ controllerUrl: options.controllerUrl ?? null,
277
+ profile: options.profile ?? null,
278
+ cwd: rootDir,
279
+ });
280
+ const resolved = resolveUserAccessTokenWithSource({
281
+ accessToken: options.accessToken ?? null,
282
+ profile: options.profile ?? null,
283
+ cwd: rootDir,
284
+ });
285
+ if (!resolved.token) {
286
+ throw formatAuthRequiredError({
287
+ retryCommand: "instafy project init",
288
+ advancedHint: "pass --access-token or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN",
289
+ });
120
290
  }
121
- const org = await resolveOrg(controllerUrl, token, options);
291
+ const retryCommand = "instafy project init";
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);
122
299
  const body = {
123
300
  projectType: options.projectType,
124
301
  ownerUserId: options.ownerUserId,
125
302
  };
126
- const response = await fetch(`${controllerUrl}/orgs/${encodeURIComponent(org.orgId)}/projects`, {
303
+ const response = await controllerFetch(controllerUrl, auth, `/orgs/${encodeURIComponent(org.orgId)}/projects`, {
127
304
  method: "POST",
128
305
  headers: {
129
- authorization: `Bearer ${token}`,
130
306
  "content-type": "application/json",
131
307
  },
132
308
  body: JSON.stringify(body),
133
309
  });
134
310
  if (!response.ok) {
135
311
  const text = await response.text().catch(() => "");
312
+ if (response.status === 401 || response.status === 403) {
313
+ throw formatAuthRejectedError({
314
+ status: response.status,
315
+ responseBody: text,
316
+ retryCommand,
317
+ });
318
+ }
136
319
  throw new Error(`Project creation failed (${response.status} ${response.statusText}): ${text}`);
137
320
  }
138
321
  const json = (await response.json());
322
+ if (!json.project_id && json.projectId) {
323
+ json.project_id = json.projectId;
324
+ }
325
+ if (!json.org_id && json.orgId) {
326
+ json.org_id = json.orgId;
327
+ }
328
+ if (!json.org_name && json.orgName) {
329
+ json.org_name = json.orgName;
330
+ }
331
+ if (!json.project_id) {
332
+ throw new Error(`Project creation response missing project id (expected project_id/projectId). Raw: ${JSON.stringify(json)}`);
333
+ }
139
334
  const manifestDir = path.join(rootDir, ".instafy");
140
335
  const manifestPath = path.join(manifestDir, "project.json");
141
336
  try {
@@ -145,6 +340,7 @@ export async function projectInit(options) {
145
340
  orgId: json.org_id ?? org.orgId ?? null,
146
341
  orgName: json.org_name ?? org.orgName ?? null,
147
342
  controllerUrl,
343
+ profile: options.profile ?? null,
148
344
  createdAt: new Date().toISOString(),
149
345
  }, null, 2), "utf8");
150
346
  }
@@ -170,3 +366,47 @@ export async function projectInit(options) {
170
366
  }
171
367
  return json;
172
368
  }
369
+ export function projectProfile(options) {
370
+ const rootDir = path.resolve(options.path ?? process.cwd());
371
+ const manifestInfo = findProjectManifest(rootDir);
372
+ if (!manifestInfo.path || !manifestInfo.manifest) {
373
+ throw new Error("No project configured. Run `instafy project init` in this folder first.");
374
+ }
375
+ const shouldUpdate = options.unset === true || typeof options.profile === "string";
376
+ const currentProfile = typeof manifestInfo.manifest.profile === "string" && manifestInfo.manifest.profile.trim()
377
+ ? manifestInfo.manifest.profile.trim()
378
+ : null;
379
+ if (!shouldUpdate) {
380
+ if (options.json) {
381
+ console.log(JSON.stringify({ ok: true, path: manifestInfo.path, profile: currentProfile }, null, 2));
382
+ }
383
+ else {
384
+ console.log(kleur.green("Project profile"));
385
+ console.log(`Manifest: ${manifestInfo.path}`);
386
+ console.log(`Profile: ${currentProfile ?? kleur.yellow("(none)")}`);
387
+ }
388
+ return { path: manifestInfo.path, profile: currentProfile };
389
+ }
390
+ const nextProfileRaw = options.unset === true ? null : typeof options.profile === "string" ? options.profile.trim() : null;
391
+ const nextProfile = nextProfileRaw && nextProfileRaw.length > 0 ? nextProfileRaw : null;
392
+ if (nextProfile) {
393
+ getInstafyProfileConfigPath(nextProfile);
394
+ }
395
+ const updated = { ...manifestInfo.manifest };
396
+ if (nextProfile) {
397
+ updated.profile = nextProfile;
398
+ }
399
+ else {
400
+ delete updated.profile;
401
+ }
402
+ fs.writeFileSync(manifestInfo.path, JSON.stringify(updated, null, 2), "utf8");
403
+ if (options.json) {
404
+ console.log(JSON.stringify({ ok: true, path: manifestInfo.path, profile: nextProfile }, null, 2));
405
+ }
406
+ else {
407
+ console.log(kleur.green("Updated project profile."));
408
+ console.log(`Manifest: ${manifestInfo.path}`);
409
+ console.log(`Profile: ${nextProfile ?? kleur.yellow("(none)")}`);
410
+ }
411
+ return { path: manifestInfo.path, profile: nextProfile };
412
+ }
package/dist/rathole.js CHANGED
@@ -52,18 +52,22 @@ export async function ensureRatholeBinary(options = {}) {
52
52
  function resolveInstallPlan() {
53
53
  const arch = process.arch;
54
54
  const platform = process.platform;
55
- if (platform === "darwin" && arch === "arm64") {
56
- return { method: "cargo", binaryName: "rathole" };
57
- }
58
55
  if (platform === "darwin") {
59
- if (arch !== "x64") {
60
- throw new Error(`Unsupported platform ${platform}/${arch} for prebuilt rathole; set RATHOLE_BIN or install via cargo.`);
56
+ if (arch === "arm64") {
57
+ return {
58
+ method: "download",
59
+ assetName: "rathole-x86_64-apple-darwin.zip",
60
+ binaryName: "rathole",
61
+ };
61
62
  }
62
- return {
63
- method: "download",
64
- assetName: "rathole-x86_64-apple-darwin.zip",
65
- binaryName: "rathole",
66
- };
63
+ if (arch === "x64") {
64
+ return {
65
+ method: "download",
66
+ assetName: "rathole-x86_64-apple-darwin.zip",
67
+ binaryName: "rathole",
68
+ };
69
+ }
70
+ throw new Error(`Unsupported platform ${platform}/${arch} for prebuilt rathole; set RATHOLE_BIN or install via cargo.`);
67
71
  }
68
72
  if (platform === "linux") {
69
73
  if (arch === "arm64") {
package/dist/runtime.js CHANGED
@@ -6,7 +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
+ import { formatAuthRejectedError } from "./errors.js";
11
+ import { findProjectManifest } from "./project-manifest.js";
12
+ import { fetchWithControllerAuth } from "./controller-fetch.js";
10
13
  const INSTAFY_DIR = path.join(os.homedir(), ".instafy");
11
14
  const STATE_FILE = path.join(INSTAFY_DIR, "cli-runtime-state.json");
12
15
  const LOG_DIR = path.join(INSTAFY_DIR, "cli-runtime-logs");
@@ -28,7 +31,7 @@ function resolveRuntimeBinary() {
28
31
  return candidate;
29
32
  }
30
33
  }
31
- throw new Error("runtime-agent binary not found. If you're in the instafy repo, run `cargo build -p runtime-agent`. Otherwise install Docker and rerun `instafy runtime:start`.");
34
+ throw new Error("runtime-agent binary not found. If you're in the instafy repo, run `cargo build -p runtime-agent`. Otherwise install Docker and rerun `instafy runtime start`.");
32
35
  }
33
36
  function tryResolveRuntimeBinary() {
34
37
  try {
@@ -142,28 +145,7 @@ function normalizeToken(value) {
142
145
  const trimmed = value.trim();
143
146
  return trimmed.length > 0 ? trimmed : null;
144
147
  }
145
- export function findProjectManifest(startDir) {
146
- let current = path.resolve(startDir);
147
- const root = path.parse(current).root;
148
- while (true) {
149
- const candidate = path.join(current, ".instafy", "project.json");
150
- if (existsSync(candidate)) {
151
- try {
152
- const parsed = JSON.parse(readFileSync(candidate, "utf8"));
153
- if (parsed?.projectId) {
154
- return { manifest: parsed, path: candidate };
155
- }
156
- }
157
- catch {
158
- // ignore malformed manifest
159
- }
160
- }
161
- if (current === root)
162
- break;
163
- current = path.dirname(current);
164
- }
165
- return { manifest: null, path: null };
166
- }
148
+ export { findProjectManifest };
167
149
  function readTokenFromFile(filePath) {
168
150
  const normalized = normalizeToken(filePath);
169
151
  if (!normalized) {
@@ -207,13 +189,28 @@ function findRatholeOnPath() {
207
189
  }
208
190
  return null;
209
191
  }
210
- function resolveControllerAccessTokenForCli(options, env, supabaseAccessToken) {
211
- return (normalizeToken(options.controllerAccessToken) ??
212
- normalizeToken(env["INSTAFY_ACCESS_TOKEN"]) ??
213
- normalizeToken(env["CONTROLLER_ACCESS_TOKEN"]) ??
214
- supabaseAccessToken ??
215
- normalizeToken(env["SUPABASE_ACCESS_TOKEN"]) ??
216
- 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" };
217
214
  }
218
215
  export async function resolveRatholeBinaryForCli(options) {
219
216
  const warn = options.warn ??
@@ -264,19 +261,31 @@ export async function resolveRatholeBinaryForCli(options) {
264
261
  export async function mintRuntimeAccessToken(params) {
265
262
  const url = params.controllerUrl.replace(/\/$/, "");
266
263
  const target = `${url}/projects/${encodeURIComponent(params.projectId)}/runtime/token`;
267
- const response = await fetch(target, {
268
- method: "POST",
269
- headers: {
270
- authorization: `Bearer ${params.controllerAccessToken}`,
271
- "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
+ scopes: params.scopes,
274
+ }),
272
275
  },
273
- body: JSON.stringify({
274
- runtimeId: params.runtimeId,
275
- scopes: params.scopes,
276
- }),
276
+ accessToken: params.controllerAccessToken,
277
+ tokenSource: params.tokenSource ?? "env",
278
+ profile: params.profile ?? null,
279
+ cwd: params.cwd ?? null,
277
280
  });
278
281
  if (!response.ok) {
279
282
  const text = await response.text().catch(() => "");
283
+ if (response.status === 401 || response.status === 403) {
284
+ throw formatAuthRejectedError({
285
+ status: response.status,
286
+ responseBody: text,
287
+ });
288
+ }
280
289
  throw new Error(`Instafy server rejected runtime token request (${response.status} ${response.statusText}): ${text}`);
281
290
  }
282
291
  const payload = (await response.json());
@@ -297,6 +306,7 @@ function isProcessAlive(pid) {
297
306
  }
298
307
  export async function runtimeStart(options) {
299
308
  const env = { ...process.env };
309
+ const cwd = process.cwd();
300
310
  const existing = readState();
301
311
  if (existing) {
302
312
  if (existing.runner === "docker" && existing.containerId && dockerContainerRunning(existing.containerId)) {
@@ -306,17 +316,20 @@ export async function runtimeStart(options) {
306
316
  throw new Error(`Runtime already running (pid ${existing.pid}) for project ${existing.projectId}. Stop it first.`);
307
317
  }
308
318
  }
309
- const manifestInfo = findProjectManifest(process.cwd());
319
+ const manifestInfo = findProjectManifest(cwd);
310
320
  const projectId = options.project ?? env["PROJECT_ID"] ?? manifestInfo.manifest?.projectId ?? null;
311
321
  if (!projectId) {
312
- throw new Error("No project configured. Run `instafy project:init` in this folder (recommended) or pass --project.");
322
+ throw new Error("No project configured. Run `instafy project init` in this folder (recommended) or pass --project.");
313
323
  }
314
324
  env["PROJECT_ID"] = projectId;
315
325
  const supabaseAccessToken = resolveSupabaseAccessToken(options, env);
316
326
  if (supabaseAccessToken) {
317
327
  env["SUPABASE_ACCESS_TOKEN"] = supabaseAccessToken;
318
328
  }
319
- let controllerAccessToken = resolveControllerAccessTokenForCli(options, env, supabaseAccessToken);
329
+ const profile = resolveActiveProfileName({ cwd });
330
+ const controllerAccessTokenResult = resolveControllerAccessTokenForCliWithSource(options, env, supabaseAccessToken, profile, cwd);
331
+ let controllerAccessToken = controllerAccessTokenResult.token;
332
+ const controllerAccessTokenSource = controllerAccessTokenResult.source;
320
333
  let runtimeAccessToken = normalizeToken(options.runtimeToken) ?? normalizeToken(env["RUNTIME_ACCESS_TOKEN"]);
321
334
  const agentKey = options.controllerToken ??
322
335
  env["INSTAFY_SERVICE_TOKEN"] ??
@@ -342,6 +355,9 @@ export async function runtimeStart(options) {
342
355
  controllerUrl: env["CONTROLLER_BASE_URL"],
343
356
  controllerAccessToken,
344
357
  projectId,
358
+ tokenSource: controllerAccessTokenSource,
359
+ profile,
360
+ cwd,
345
361
  });
346
362
  }
347
363
  if (runtimeAccessToken) {
@@ -375,6 +391,9 @@ export async function runtimeStart(options) {
375
391
  controllerAccessToken,
376
392
  projectId,
377
393
  runtimeId: env["RUNTIME_ID"],
394
+ tokenSource: controllerAccessTokenSource,
395
+ profile,
396
+ cwd,
378
397
  });
379
398
  }
380
399
  if (!originToken) {
@@ -513,7 +532,14 @@ export async function runtimeStart(options) {
513
532
  const started = spawnSync("docker", runArgs, { encoding: "utf8" });
514
533
  if (started.status !== 0) {
515
534
  const stderr = String(started.stderr ?? "").trim();
516
- throw new Error(`docker run failed: ${stderr || "unknown error"}`);
535
+ const normalized = (stderr || "").toLowerCase();
536
+ const hints = [];
537
+ if (image.startsWith("ghcr.io/") && (normalized.includes("not found") || normalized.includes("denied") || normalized.includes("unauthorized") || normalized.includes("manifest unknown"))) {
538
+ hints.push("If this is a GHCR image, ensure it's published and you are authenticated (`docker login ghcr.io`).");
539
+ }
540
+ hints.push("Override the image with `INSTAFY_RUNTIME_AGENT_IMAGE=... instafy runtime start`.");
541
+ const suffix = hints.length > 0 ? `\n\nNext:\n- ${hints.join("\n- ")}` : "";
542
+ throw new Error(`docker run failed: ${stderr || "unknown error"}${suffix}`);
517
543
  }
518
544
  const containerId = String(started.stdout ?? "").trim();
519
545
  if (!containerId) {
@@ -753,15 +779,27 @@ export async function runtimeStop(options) {
753
779
  }
754
780
  }
755
781
  export async function runtimeToken(options) {
782
+ const cwd = process.cwd();
756
783
  const controllerUrl = options.controllerUrl ??
757
784
  process.env["INSTAFY_SERVER_URL"] ??
758
785
  process.env["CONTROLLER_BASE_URL"] ??
759
786
  "http://127.0.0.1:8788";
787
+ const profile = resolveActiveProfileName({ cwd });
788
+ const stored = resolveConfiguredAccessToken({ profile, cwd });
789
+ const tokenSource = options.controllerAccessToken
790
+ ? "explicit"
791
+ : process.env["INSTAFY_ACCESS_TOKEN"] ||
792
+ process.env["CONTROLLER_ACCESS_TOKEN"] ||
793
+ process.env["SUPABASE_ACCESS_TOKEN"]
794
+ ? "env"
795
+ : stored
796
+ ? "config"
797
+ : "none";
760
798
  const token = options.controllerAccessToken ??
761
799
  process.env["INSTAFY_ACCESS_TOKEN"] ??
762
800
  process.env["CONTROLLER_ACCESS_TOKEN"] ??
763
801
  process.env["SUPABASE_ACCESS_TOKEN"] ??
764
- resolveConfiguredAccessToken();
802
+ stored;
765
803
  if (!token) {
766
804
  throw new Error("Login required. Run `instafy login` or pass --access-token / set SUPABASE_ACCESS_TOKEN.");
767
805
  }
@@ -771,6 +809,9 @@ export async function runtimeToken(options) {
771
809
  projectId: options.project,
772
810
  runtimeId: options.runtimeId,
773
811
  scopes: options.scopes,
812
+ tokenSource: tokenSource === "none" ? "env" : tokenSource,
813
+ profile,
814
+ cwd,
774
815
  });
775
816
  if (options.json) {
776
817
  console.log(JSON.stringify({ token: minted }));