@instafy/cli 0.1.8-staging.371 → 0.1.8-staging.373

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.
Files changed (2) hide show
  1. package/dist/project.js +225 -106
  2. package/package.json +1 -1
package/dist/project.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import kleur from "kleur";
4
4
  import { createInterface } from "node:readline/promises";
5
+ import { emitKeypressEvents } from "node:readline";
5
6
  import { stdin as input, stdout as output } from "node:process";
6
7
  import { getInstafyProfileConfigPath, resolveConfiguredStudioUrl, resolveControllerUrl, resolveUserAccessToken, } from "./config.js";
7
8
  import { formatAuthRequiredError } from "./errors.js";
@@ -32,6 +33,163 @@ async function fetchOrgProjects(controllerUrl, token, orgId) {
32
33
  const body = (await response.json());
33
34
  return Array.isArray(body.projects) ? body.projects : [];
34
35
  }
36
+ async function promptSelectIndex(params) {
37
+ const ttyInput = input;
38
+ const isRawModeAvailable = typeof ttyInput.setRawMode === "function";
39
+ const canUseArrowKeys = Boolean(input.isTTY && output.isTTY && isRawModeAvailable && process.env.TERM !== "dumb");
40
+ if (!canUseArrowKeys) {
41
+ const rl = createInterface({ input, output });
42
+ try {
43
+ while (true) {
44
+ const raw = (await rl.question(`Select option (default ${params.defaultIndex}): `)).trim();
45
+ if (!raw) {
46
+ return params.defaultIndex;
47
+ }
48
+ const selected = Number(raw);
49
+ if (!Number.isFinite(selected) || selected < 0 || selected >= params.options.length) {
50
+ console.log(kleur.red("Invalid selection."));
51
+ continue;
52
+ }
53
+ return selected;
54
+ }
55
+ }
56
+ finally {
57
+ rl.close();
58
+ }
59
+ }
60
+ return await new Promise((resolve, reject) => {
61
+ const options = params.options;
62
+ const defaultIndex = Math.min(Math.max(params.defaultIndex, 0), Math.max(0, options.length - 1));
63
+ let selectedIndex = defaultIndex;
64
+ let renderedLineCount = 0;
65
+ let typedDigits = "";
66
+ let typedResetTimeout = null;
67
+ let completed = false;
68
+ const previousRawMode = Boolean(ttyInput.isRaw);
69
+ const render = (hintOverride) => {
70
+ const hint = hintOverride ?? "Use ↑/↓ to move, Enter to select, or type a number.";
71
+ const lines = [
72
+ kleur.yellow(`${params.title}:`),
73
+ ...options.map((label, index) => {
74
+ const prefix = index === selectedIndex ? kleur.cyan(">") : " ";
75
+ const number = kleur.gray(`${index})`);
76
+ return ` ${prefix} ${number} ${label}`;
77
+ }),
78
+ kleur.gray(hint),
79
+ ];
80
+ if (renderedLineCount > 0) {
81
+ output.write(`\x1b[${renderedLineCount}A`);
82
+ }
83
+ for (const line of lines) {
84
+ output.write(`\x1b[2K\r${line}\n`);
85
+ }
86
+ renderedLineCount = lines.length;
87
+ };
88
+ const cleanup = () => {
89
+ if (completed)
90
+ return;
91
+ completed = true;
92
+ input.off("keypress", onKeypress);
93
+ if (typedResetTimeout) {
94
+ clearTimeout(typedResetTimeout);
95
+ typedResetTimeout = null;
96
+ }
97
+ try {
98
+ ttyInput.setRawMode(previousRawMode);
99
+ }
100
+ catch {
101
+ // ignore
102
+ }
103
+ output.write("\x1b[?25h"); // show cursor
104
+ output.write("\n");
105
+ };
106
+ const onKeypress = (_chunk, key) => {
107
+ if (!key)
108
+ return;
109
+ if (key.ctrl && key.name === "c") {
110
+ cleanup();
111
+ reject(new Error("Cancelled."));
112
+ return;
113
+ }
114
+ if (_chunk && /^[0-9]$/.test(_chunk)) {
115
+ typedDigits += _chunk;
116
+ if (typedResetTimeout) {
117
+ clearTimeout(typedResetTimeout);
118
+ }
119
+ typedResetTimeout = setTimeout(() => {
120
+ typedDigits = "";
121
+ render();
122
+ }, 1200);
123
+ const candidate = Number(typedDigits);
124
+ if (Number.isFinite(candidate) && candidate >= 0 && candidate < options.length) {
125
+ selectedIndex = candidate;
126
+ }
127
+ render(typedDigits ? `Number: ${typedDigits}` : undefined);
128
+ return;
129
+ }
130
+ if (key.name === "backspace") {
131
+ typedDigits = typedDigits.slice(0, -1);
132
+ render(typedDigits ? `Number: ${typedDigits}` : undefined);
133
+ return;
134
+ }
135
+ if (key.name === "up") {
136
+ typedDigits = "";
137
+ selectedIndex = selectedIndex <= 0 ? options.length - 1 : selectedIndex - 1;
138
+ render();
139
+ return;
140
+ }
141
+ if (key.name === "down") {
142
+ typedDigits = "";
143
+ selectedIndex = selectedIndex >= options.length - 1 ? 0 : selectedIndex + 1;
144
+ render();
145
+ return;
146
+ }
147
+ if (key.name === "return" || key.name === "enter") {
148
+ if (typedDigits) {
149
+ const candidate = Number(typedDigits);
150
+ if (Number.isFinite(candidate) && candidate >= 0 && candidate < options.length) {
151
+ selectedIndex = candidate;
152
+ }
153
+ }
154
+ const picked = selectedIndex;
155
+ cleanup();
156
+ resolve(picked);
157
+ }
158
+ };
159
+ try {
160
+ emitKeypressEvents(input);
161
+ ttyInput.setRawMode(true);
162
+ output.write("\x1b[?25l"); // hide cursor
163
+ input.on("keypress", onKeypress);
164
+ input.resume();
165
+ render();
166
+ }
167
+ catch (error) {
168
+ cleanup();
169
+ reject(error instanceof Error ? error : new Error(String(error)));
170
+ }
171
+ });
172
+ }
173
+ async function createOrganization(controllerUrl, token, payload) {
174
+ const response = await fetch(`${controllerUrl}/orgs`, {
175
+ method: "POST",
176
+ headers: {
177
+ authorization: `Bearer ${token}`,
178
+ "content-type": "application/json",
179
+ },
180
+ body: JSON.stringify(payload),
181
+ });
182
+ if (!response.ok) {
183
+ const text = await response.text().catch(() => "");
184
+ throw new Error(`Organization creation failed (${response.status} ${response.statusText}): ${text}`);
185
+ }
186
+ const json = (await response.json());
187
+ const orgId = json.org_id ?? json.orgId ?? json.id;
188
+ if (!orgId) {
189
+ throw new Error(`Organization creation response missing org id (expected org_id/orgId/id). Raw: ${JSON.stringify(json)}`);
190
+ }
191
+ return { orgId, orgName: json.org_name ?? json.orgName ?? null };
192
+ }
35
193
  async function resolveOrg(controllerUrl, token, options) {
36
194
  if (options.orgId) {
37
195
  return { orgId: options.orgId, orgName: options.orgName ?? null };
@@ -40,6 +198,23 @@ async function resolveOrg(controllerUrl, token, options) {
40
198
  const orgName = options.orgName?.trim() || null;
41
199
  const studioUrl = resolveConfiguredStudioUrl({ profile: options.profile ?? null }) ?? "https://staging.instafy.dev";
42
200
  const studioOrgUrl = `${studioUrl.replace(/\/$/, "")}/studio?panel=settings`;
201
+ const allowInteractive = input.isTTY && options.json !== true;
202
+ async function promptAndCreateOrg(rl) {
203
+ const enteredName = (await rl.question("Organization name (default: Personal): ")).trim();
204
+ const chosenName = enteredName || "Personal";
205
+ const enteredSlug = (await rl.question("Organization slug (optional): ")).trim();
206
+ const payload = {
207
+ orgName: chosenName,
208
+ };
209
+ if (options.ownerUserId) {
210
+ payload.ownerUserId = options.ownerUserId;
211
+ }
212
+ if (enteredSlug) {
213
+ payload.orgSlug = enteredSlug;
214
+ }
215
+ const created = await createOrganization(controllerUrl, token, payload);
216
+ return { orgId: created.orgId, orgName: created.orgName ?? chosenName };
217
+ }
43
218
  if (orgSlug) {
44
219
  const orgs = await fetchOrganizations(controllerUrl, token);
45
220
  const matches = orgs.filter((org) => org.slug === orgSlug);
@@ -49,11 +224,8 @@ async function resolveOrg(controllerUrl, token, options) {
49
224
  }
50
225
  if (!orgSlug && !orgName) {
51
226
  const orgs = await fetchOrganizations(controllerUrl, token);
52
- if (orgs.length === 1) {
53
- return { orgId: orgs[0].id, orgName: orgs[0].name ?? null };
54
- }
55
227
  if (orgs.length === 0) {
56
- if (input.isTTY) {
228
+ if (allowInteractive) {
57
229
  console.log(kleur.yellow("No organizations found for this account."));
58
230
  console.log("");
59
231
  console.log(`Create one in Studio: ${kleur.cyan(studioOrgUrl)}`);
@@ -64,34 +236,7 @@ async function resolveOrg(controllerUrl, token, options) {
64
236
  if (confirm && ["n", "no"].includes(confirm)) {
65
237
  throw new Error("No organization selected.");
66
238
  }
67
- const enteredName = (await rl.question("Organization name (default: Personal): ")).trim();
68
- const chosenName = enteredName || "Personal";
69
- const enteredSlug = (await rl.question("Organization slug (optional): ")).trim();
70
- const payload = {
71
- orgName: chosenName,
72
- ownerUserId: options.ownerUserId,
73
- };
74
- if (enteredSlug) {
75
- payload.orgSlug = enteredSlug;
76
- }
77
- const response = await fetch(`${controllerUrl}/orgs`, {
78
- method: "POST",
79
- headers: {
80
- authorization: `Bearer ${token}`,
81
- "content-type": "application/json",
82
- },
83
- body: JSON.stringify(payload),
84
- });
85
- if (!response.ok) {
86
- const text = await response.text().catch(() => "");
87
- throw new Error(`Organization creation failed (${response.status} ${response.statusText}): ${text}`);
88
- }
89
- const json = (await response.json());
90
- const createdId = json.org_id ?? json.orgId ?? json.id;
91
- if (!createdId) {
92
- throw new Error(`Organization creation response missing org id (expected org_id/orgId/id). Raw: ${JSON.stringify(json)}`);
93
- }
94
- return { orgId: createdId, orgName: json.org_name ?? json.orgName ?? chosenName };
239
+ return await promptAndCreateOrg(rl);
95
240
  }
96
241
  finally {
97
242
  rl.close();
@@ -99,96 +244,70 @@ async function resolveOrg(controllerUrl, token, options) {
99
244
  }
100
245
  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\"`);
101
246
  }
102
- if (orgs.length > 1) {
103
- if (input.isTTY) {
104
- console.log(kleur.yellow("Multiple organizations found. Choose which one to use:"));
105
- console.log(` 0) ${kleur.cyan("+ Create a new organization")}`);
106
- orgs.forEach((org, index) => {
107
- const slug = org.slug ? ` · ${org.slug}` : "";
108
- console.log(` ${index + 1}) ${org.name}${slug} (${org.id})`);
109
- });
110
- console.log("");
247
+ if (orgs.length === 1) {
248
+ return { orgId: orgs[0].id, orgName: orgs[0].name ?? null };
249
+ }
250
+ if (allowInteractive) {
251
+ const options = [
252
+ "+ Create a new organization",
253
+ ...orgs.map((org) => `${org.name}${org.slug ? ` · ${org.slug}` : ""} (${org.id})`),
254
+ ];
255
+ const pickedIndex = await promptSelectIndex({
256
+ title: "Choose an organization for this project",
257
+ options,
258
+ defaultIndex: 1,
259
+ });
260
+ if (pickedIndex === 0) {
111
261
  const rl = createInterface({ input, output });
112
262
  try {
113
- while (true) {
114
- const answer = (await rl.question("Select org number: ")).trim();
115
- const selected = Number(answer);
116
- if (selected === 0) {
117
- const enteredName = (await rl.question("Organization name (default: Personal): ")).trim();
118
- const chosenName = enteredName || "Personal";
119
- const enteredSlug = (await rl.question("Organization slug (optional): ")).trim();
120
- const payload = {
121
- orgName: chosenName,
122
- ownerUserId: options.ownerUserId,
123
- };
124
- if (enteredSlug) {
125
- payload.orgSlug = enteredSlug;
126
- }
127
- const response = await fetch(`${controllerUrl}/orgs`, {
128
- method: "POST",
129
- headers: {
130
- authorization: `Bearer ${token}`,
131
- "content-type": "application/json",
132
- },
133
- body: JSON.stringify(payload),
134
- });
135
- if (!response.ok) {
136
- const text = await response.text().catch(() => "");
137
- throw new Error(`Organization creation failed (${response.status} ${response.statusText}): ${text}`);
138
- }
139
- const json = (await response.json());
140
- const createdId = json.org_id ?? json.orgId ?? json.id;
141
- if (!createdId) {
142
- throw new Error(`Organization creation response missing org id (expected org_id/orgId/id). Raw: ${JSON.stringify(json)}`);
143
- }
144
- return { orgId: createdId, orgName: json.org_name ?? json.orgName ?? chosenName };
145
- }
146
- if (!Number.isFinite(selected) || selected < 1 || selected > orgs.length) {
147
- console.log(kleur.red("Invalid selection."));
148
- continue;
149
- }
150
- const picked = orgs[selected - 1];
151
- return { orgId: picked.id, orgName: picked.name ?? null };
152
- }
263
+ return await promptAndCreateOrg(rl);
153
264
  }
154
265
  finally {
155
266
  rl.close();
156
267
  }
157
268
  }
158
- throw new Error("Multiple organizations found.\n\nNext:\n- instafy org list\n- instafy project init --org-id <uuid>");
269
+ const pickedOrg = orgs[pickedIndex - 1];
270
+ return { orgId: pickedOrg.id, orgName: pickedOrg.name ?? null };
271
+ }
272
+ throw new Error("Multiple organizations found.\n\nNext:\n- instafy org list\n- instafy project init --org-id <uuid>");
273
+ }
274
+ if (orgSlug && !orgName) {
275
+ if (allowInteractive) {
276
+ const rl = createInterface({ input, output });
277
+ try {
278
+ console.log(kleur.yellow("Organization slug did not match an existing org."));
279
+ console.log(`Create one in Studio: ${kleur.cyan(studioOrgUrl)}`);
280
+ console.log("");
281
+ const enteredName = (await rl.question("Organization name (default: Personal): ")).trim();
282
+ const chosenName = enteredName || "Personal";
283
+ const payload = { orgName: chosenName, orgSlug };
284
+ if (options.ownerUserId) {
285
+ payload.ownerUserId = options.ownerUserId;
286
+ }
287
+ const created = await createOrganization(controllerUrl, token, payload);
288
+ return { orgId: created.orgId, orgName: created.orgName ?? chosenName };
289
+ }
290
+ finally {
291
+ rl.close();
292
+ }
159
293
  }
294
+ 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}"`);
160
295
  }
161
296
  const payload = {};
162
- const resolvedName = orgName ||
163
- orgSlug ||
164
- null;
165
- if (!resolvedName) {
166
- 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\"`);
297
+ if (orgName) {
298
+ payload.orgName = orgName;
167
299
  }
168
- payload.orgName = resolvedName;
169
- if (orgSlug)
300
+ if (orgSlug) {
170
301
  payload.orgSlug = orgSlug;
302
+ }
171
303
  if (options.ownerUserId) {
172
304
  payload.ownerUserId = options.ownerUserId;
173
305
  }
174
- const response = await fetch(`${controllerUrl}/orgs`, {
175
- method: "POST",
176
- headers: {
177
- authorization: `Bearer ${token}`,
178
- "content-type": "application/json",
179
- },
180
- body: JSON.stringify(payload),
181
- });
182
- if (!response.ok) {
183
- const text = await response.text().catch(() => "");
184
- throw new Error(`Organization creation failed (${response.status} ${response.statusText}): ${text}`);
185
- }
186
- const json = (await response.json());
187
- const orgId = json.org_id ?? json.orgId ?? json.id;
188
- if (!orgId) {
189
- throw new Error(`Organization creation response missing org id (expected org_id/orgId/id). Raw: ${JSON.stringify(json)}`);
306
+ if (!payload.orgName) {
307
+ 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\"`);
190
308
  }
191
- return { orgId, orgName: json.org_name ?? json.orgName ?? null };
309
+ const created = await createOrganization(controllerUrl, token, payload);
310
+ return { orgId: created.orgId, orgName: created.orgName ?? orgName ?? null };
192
311
  }
193
312
  export async function listProjects(options) {
194
313
  const controllerUrl = resolveControllerUrl({ controllerUrl: options.controllerUrl ?? null });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instafy/cli",
3
- "version": "0.1.8-staging.371",
3
+ "version": "0.1.8-staging.373",
4
4
  "description": "Run Instafy projects locally, link folders to Studio, and share previews/webhooks via tunnels.",
5
5
  "private": false,
6
6
  "publishConfig": {