insta 0.0.67 → 0.0.68

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.
@@ -1,4 +1,5 @@
1
- import { ApiClient, requireProject } from '../api.js';
1
+ import { ApiClient, ApiError, requireProject } from '../api.js';
2
+ import { agentMode } from '../agent.js';
2
3
  import { info, printJson } from '../util.js';
3
4
  import { resolveSoleService, parsePort, q } from './services.js';
4
5
  export function parseRepoRef(raw) {
@@ -74,21 +75,95 @@ export function repoLine(serviceName, s) {
74
75
  : `; watch paths ${s.watch_paths.join(', ')} are stored but cannot apply`;
75
76
  return `compute ${serviceName}: deploys from ${s.owner}/${s.repo}@${s.branch}${where} — ${how}${only}`;
76
77
  }
77
- export async function findInstalledRepo(api, orgId, ref) {
78
+ const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
79
+ // Node fires a timer of ~1ms for anything it cannot represent — a huge delay overflows, a negative one
80
+ // is clamped — so both ends are pinned or the wait becomes a hot poll of GitHub through us.
81
+ const pollDelay = (s) => Math.min(Math.max(s, 1), 60);
82
+ // GitHub shows the person a code to type; the platform holds the device code and finishes the exchange,
83
+ // so nothing secret passes through the CLI.
84
+ export async function authorizeTerminal(api, orgId, wait = sleepSeconds) {
85
+ const start = await api.request('POST', `/orgs/${encodeURIComponent(orgId)}/github/device`, {});
86
+ const deadline = Date.parse(start.expiresAt);
87
+ // A NaN deadline makes every comparison false, which reads as an instant expiry — or, inverted, as a
88
+ // loop with no way out. Fail on it rather than guess which.
89
+ if (!Number.isFinite(deadline))
90
+ throw new Error('malformed device authorization response (missing expiresAt) — is the platform up to date?');
91
+ // stderr, not stdout: --json must stay one parseable document.
92
+ const say = (line) => process.stderr.write(line + '\n');
93
+ say('this terminal is not authorized with GitHub yet — authorize it once:');
94
+ say(` open ${start.verificationUri} and enter the code ${start.userCode}`);
95
+ say('waiting for you to confirm… (ctrl-c to abort)');
96
+ const stopAt = Math.min(deadline, Date.now() + 3600_000); // no device code sensibly outlives an hour
97
+ const asked = Number(start.interval);
98
+ let interval = pollDelay(Number.isFinite(asked) && asked > 0 ? asked : 5);
99
+ while (Date.now() < stopAt) {
100
+ await wait(interval);
101
+ let answer;
102
+ try {
103
+ answer = await api.request('POST', `/orgs/${encodeURIComponent(orgId)}/github/device/poll`, { state: start.state });
104
+ }
105
+ catch (e) {
106
+ // A dropped link, or the per-IP limiter this cadence already tripped on the login flow, must not
107
+ // end an authorization the person may be one click from finishing.
108
+ if (e instanceof ApiError && e.status !== 429)
109
+ throw e;
110
+ interval = pollDelay(interval + 5);
111
+ continue;
112
+ }
113
+ if (!answer.pending) {
114
+ if (!answer.repos)
115
+ throw new Error('the GitHub authorization completed but returned no repositories — is the platform up to date?');
116
+ return answer.repos;
117
+ }
118
+ // GitHub asking for more room between polls, relayed by the platform; ignoring it gets us limited.
119
+ interval = pollDelay(interval + Math.max(Number(answer.slowDownBy) || 0, 0));
120
+ }
121
+ throw new Error('the GitHub authorization expired before it was confirmed — run the command again');
122
+ }
123
+ // Only the platform's own "you have no usable authorization" may send the person to GitHub: any other
124
+ // failure is real, and a device flow cannot fix it.
125
+ const needsAuthorization = (e) => e instanceof ApiError && e.status === 400 && /not linked|no longer accepted/i.test(e.message);
126
+ // The repositories THIS caller's GitHub account can reach — the same question the platform asks again
127
+ // when the connect lands, so a repo missing here would be refused there anyway.
128
+ // Someone has to read the code and type it at GitHub. A terminal qualifies, and so does agent mode —
129
+ // an agent relays the URL to the person driving it — but --json and a bare pipe have no reader, and a
130
+ // ten-minute wait there is a stall where the old code failed with something to act on.
131
+ export function canAuthorizeHere(opts = {}) {
132
+ if (opts.json)
133
+ return false;
134
+ return !!agentMode() || !!process.stderr.isTTY;
135
+ }
136
+ export async function findCallerRepo(api, orgId, ref, authorize = authorizeTerminal, canAuthorize = canAuthorizeHere()) {
78
137
  if (!orgId)
79
138
  throw new Error('this directory is linked without an org — set INSTA_ORG_ID alongside INSTA_PROJECT_ID, or link it with `insta project link`');
80
- const { installations = [] } = await api.request('GET', `/github/installations?orgId=${encodeURIComponent(orgId)}`);
81
- if (installations.length === 0) {
82
- throw new Error('no GitHub App installation for this org — connect GitHub in the console first (Add Service → GitHub Repo → Connect GitHub), or pass --public for a public repository');
139
+ let repos;
140
+ try {
141
+ repos = (await api.request('POST', `/orgs/${encodeURIComponent(orgId)}/github/repos`, {})).repos ?? [];
83
142
  }
84
- for (const inst of installations) {
85
- const { repos = [] } = await api.request('GET', `/github/installations/${encodeURIComponent(inst.installation_id)}/repos?orgId=${encodeURIComponent(orgId)}`);
86
- const hit = repos.find((r) => r.owner.toLowerCase() === ref.owner.toLowerCase() && r.repo.toLowerCase() === ref.repo.toLowerCase());
87
- if (hit)
88
- return { installationId: Number(inst.installation_id), repoId: hit.id };
143
+ catch (e) {
144
+ // Two 403s carry an action; a third kind would be guessed at, so it is rethrown as the platform put it.
145
+ if (e instanceof ApiError && e.status === 403 && /unclassified_agent_action/.test(e.message)) {
146
+ throw new Error('this backend does not let an agent authorize GitHub yet — connect the repository from the console, or pass --public for a public repository');
147
+ }
148
+ if (e instanceof ApiError && e.status === 403 && /requires admin/i.test(e.message)) {
149
+ throw new Error('connecting a repository needs the org admin role — ask an admin to connect it, or pass --public for a public repository');
150
+ }
151
+ if (!needsAuthorization(e))
152
+ throw e;
153
+ if (!canAuthorize) {
154
+ throw new Error('this GitHub account is not authorized for InstaCloud yet, and nothing here can read the code GitHub shows — run `insta compute connect-repo` from a terminal, connect the repository from the console, or pass --public for a public repository');
155
+ }
156
+ repos = await authorize(api, orgId);
89
157
  }
90
- const accounts = installations.map((i) => i.account_login ?? i.installation_id).join(', ');
91
- throw new Error(`${ref.owner}/${ref.repo} is not visible to the org's GitHub App installation (installed on: ${accounts}) — grant the App access to it in the console (Configure GitHub app), or pass --public for a public repository`);
158
+ const whole = (n) => n !== null && n !== '' && Number.isInteger(Number(n)) && Number(n) > 0;
159
+ const hit = repos.find((r) => r.owner.toLowerCase() === ref.owner.toLowerCase() && r.repo.toLowerCase() === ref.repo.toLowerCase());
160
+ if (hit && whole(hit.installationId) && whole(hit.id))
161
+ return { installationId: Number(hit.installationId), repoId: Number(hit.id) };
162
+ if (hit)
163
+ throw new Error(`${ref.owner}/${ref.repo} came back without an installation to build it through — reconnect GitHub in the console, or pass --public for a public repository`);
164
+ if (repos.length === 0)
165
+ throw new Error('the InstaCloud GitHub App reaches none of your repositories — install it on the account that owns this one (console → Add Service → GitHub Repo → Connect GitHub), or pass --public for a public repository');
166
+ throw new Error(`${ref.owner}/${ref.repo} is not one your GitHub account can reach through the App — grant the App access to it on GitHub, or pass --public for a public repository`);
92
167
  }
93
168
  async function targetService(api, projectId, branch, serviceName) {
94
169
  const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
@@ -112,7 +187,7 @@ export async function computeConnectRepo(rawRef, serviceName, opts) {
112
187
  const svc = await targetService(api, p.projectId, opts.branch ?? p.branch, serviceName);
113
188
  const src = opts.public
114
189
  ? { source: 'public', ...ref }
115
- : { source: 'app', ...(await findInstalledRepo(api, p.orgId, ref)), ...ref };
190
+ : { source: 'app', ...(await findCallerRepo(api, p.orgId, ref, authorizeTerminal, canAuthorizeHere(opts))), ...ref };
116
191
  // Detection must scan the branch that will be built: the build refuses commands that differ from what it detects there.
117
192
  const detected = await api.request('POST', `/projects/${p.projectId}/github/detect`, { ...src, ...(opts.repoBranch ? { ref: opts.repoBranch } : {}) });
118
193
  const candidate = pickCandidate(detected.services, opts.rootDir);
package/dist/index.js CHANGED
@@ -255,7 +255,7 @@ for (const [flags, description] of computeCmd.EXEC_OPTIONS)
255
255
  execCmd.option(flags, description);
256
256
  compute.command('repo [service]').description('Show what a compute service deploys from: the image it runs, or the GitHub repository — owner/repo, the branch it builds, root directory, which paths a push must change to redeploy it, and whether pushes redeploy it at all')
257
257
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeRepo(service, o)));
258
- compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be reachable through the org's GitHub App installation — connect GitHub in the console first (Add Service → GitHub Repo) — or be public. Build and start commands come from detection and cannot be set. Connecting again replaces the service's current source")
258
+ compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be one your own GitHub account can reach through the InstaCloud App, or be public; the first connect from a terminal prints a GitHub URL and a code to authorize it once. Build and start commands come from detection and cannot be set. Connecting again replaces the service's current source")
259
259
  .option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
260
260
  .option('--root-dir <dir>', 'the directory of the repo to build (a monorepo with several deployable directories lists them and exits 1 without it)')
261
261
  .option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [