insta 0.0.68 → 0.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Native binary, no Node required (macOS / Linux / WSL). Installs to `~/.insta/bin
16
16
  verifies the download against `SHA256SUMS`:
17
17
 
18
18
  ```bash
19
- curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh
19
+ curl -fsSL https://raw.githubusercontent.com/InsForge/instacloud-cli/main/install.sh | sh
20
20
  ```
21
21
 
22
22
  From npm:
@@ -48,7 +48,7 @@ On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on
48
48
  skill + MCP steps it then runs still need Node — the skills tool runs via npx). Never run it
49
49
  on native Windows — PowerShell's `curl` alias and the WSL `bash` shim break it; use npx
50
50
  above, or download `insta-windows-x64.exe` from the
51
- [releases page](https://github.com/InsForge/insta-cli/releases):
51
+ [releases page](https://github.com/InsForge/instacloud-cli/releases):
52
52
 
53
53
  ```bash
54
54
  curl -fsSL agents.instacloud.com | sh
@@ -74,7 +74,9 @@ insta deploy .
74
74
  opt-in, so you add only what you need. `secrets` writes the current branch's user-defined
75
75
  secrets to `./.env` (the postgres connection string is read with `insta db url`). `deploy .`
76
76
  builds the directory remotely and ships it to the branch's compute
77
- service; it needs a `Dockerfile`, but no local Docker.
77
+ service, with no local Docker. Whether it needs a `Dockerfile` depends on where the
78
+ service runs: on insta-compute it is optional, and a directory without one is built
79
+ by nixpacks on the build gateway; on Fly-backed services one is still required.
78
80
 
79
81
  ## Authentication
80
82
 
@@ -157,7 +159,7 @@ other, so switching environments drops the stored session and you log in again.
157
159
  | control plane | `api.instacloud.com` | `api.staging.instacloud.com` |
158
160
  | MCP server | `mcp.instacloud.com/mcp` | `mcp.staging.instacloud.com/mcp` |
159
161
  | MCP registers as | `insta-cloud` | `insta-cloud-staging` |
160
- | agent skills | `InsForge/insta-skills` | `InsForge/insta-skills#devel` |
162
+ | agent skills | `InsForge/instacloud-skills` | `InsForge/instacloud-skills#devel` |
161
163
  | CLI channel | latest stable release | newest prerelease, else stable |
162
164
 
163
165
  ```bash
@@ -180,7 +182,7 @@ That host is a CloudFront cache, so after a change to the installer it can serve
180
182
  previous copy for up to about a day. This form is equivalent and always current:
181
183
 
182
184
  ```bash
183
- curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging
185
+ curl -fsSL https://raw.githubusercontent.com/InsForge/instacloud-cli/main/install.sh | sh -s -- --agents --staging
184
186
  ```
185
187
 
186
188
  If the environment cannot be applied — an installed CLI older than 0.0.23 has no `insta
@@ -204,7 +206,7 @@ build never reaches a production installer.
204
206
  ## Commands
205
207
 
206
208
  `insta --help` is the authoritative list. For flags, approval gates and plan limits, see the
207
- [full command reference](https://github.com/InsForge/insta-skills/blob/main/insta/cli-reference.md).
209
+ [full command reference](https://github.com/InsForge/instacloud-skills/blob/main/insta/cli-reference.md).
208
210
 
209
211
  | Command | What it covers |
210
212
  |---|---|
@@ -238,6 +240,7 @@ build never reaches a production installer.
238
240
  |---|---|
239
241
  | `~/.insta/config.json` | API URL, access and refresh tokens, user, auto-update preference |
240
242
  | `./.insta/project.json` | Project id, org id, current branch |
243
+ | `./.insta/link-plane.json` | The control-plane URL this machine linked against. Gitignored and per machine; a link made against a different control plane is refused rather than reused. The home directory is never a project |
241
244
 
242
245
  | Variable | Effect |
243
246
  |---|---|
@@ -252,7 +255,7 @@ build never reaches a production installer.
252
255
  ## Agent skills
253
256
 
254
257
  The `insta` skill and its task guides live in
255
- [InsForge/insta-skills](https://github.com/InsForge/insta-skills). `insta setup agent`
258
+ [InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta setup agent`
256
259
  installs it user-globally for every coding agent on the machine. `insta project create` and
257
260
  `insta project link` additionally install the stack skills (Tigris, Better Auth) into the
258
261
  project, along with the `insta observe` credential-audit hook. Postgres needs no stack
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Thin API client over the platform control-plane. Handles bearer auth + one-shot refresh on 401.
2
2
  // 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
3
- import { readGlobal, writeGlobal, readProject, writeProject } from './config.js';
3
+ import { readGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage } from './config.js';
4
4
  import { autoResolveProject, promptChoice } from './resolve-project.js';
5
5
  import { die } from './util.js';
6
6
  import { USER_AGENT } from './version.js';
@@ -77,7 +77,7 @@ export class ApiClient {
77
77
  async raw(method, path, body, auth, scope = {}) {
78
78
  let r = await this.fetch(method, path, body, auth, scope);
79
79
  if (r.status === 401 && auth && this.cfg.refreshToken) {
80
- if (await this.refresh())
80
+ if (await this.refresh(scope.signal))
81
81
  r = await this.fetch(method, path, body, auth, scope);
82
82
  }
83
83
  return r;
@@ -92,6 +92,7 @@ export class ApiClient {
92
92
  method,
93
93
  headers,
94
94
  body: body === undefined ? undefined : JSON.stringify(body),
95
+ signal: scope.signal,
95
96
  });
96
97
  const text = await res.text();
97
98
  let parsed = null;
@@ -103,9 +104,9 @@ export class ApiClient {
103
104
  }
104
105
  return { status: res.status, body: parsed };
105
106
  }
106
- async refresh() {
107
+ async refresh(signal) {
107
108
  try {
108
- const res = await this.fetch('POST', '/auth/refresh', { refreshToken: this.cfg.refreshToken }, false);
109
+ const res = await this.fetch('POST', '/auth/refresh', { refreshToken: this.cfg.refreshToken }, false, { signal });
109
110
  if (res.status >= 400)
110
111
  return false;
111
112
  this.cfg.accessToken = res.body.accessToken;
@@ -113,7 +114,12 @@ export class ApiClient {
113
114
  await this.persist();
114
115
  return true;
115
116
  }
116
- catch {
117
+ catch (e) {
118
+ // Refresh belongs to the original request's time budget. Do not turn its cancellation
119
+ // into the earlier 401: the caller needs the abort to report a timeout or cancellation.
120
+ signal?.throwIfAborted();
121
+ if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TimeoutError'))
122
+ throw e;
117
123
  return false;
118
124
  }
119
125
  }
@@ -121,12 +127,20 @@ export class ApiClient {
121
127
  // Resolve the linked project (./.insta/project.json), or null.
122
128
  export async function linkedProject() { return readProject(); }
123
129
  // Resolve the linked project or exit with guidance.
124
- export async function requireProject() {
125
- const p = await readProject();
126
- if (p)
127
- return p;
130
+ export async function requireProject(deps = {}) {
131
+ const r = await resolveProjectLink(deps.cwd);
132
+ // Fail CLOSED on a link made against another control plane. Treating it as "unlinked" sent this
133
+ // into auto-resolve, which with exactly one project on the new plane picks it without a prompt
134
+ // and SAVES — so a read-only command replaced the committed team binding, and pointing back
135
+ // flipped it again. Only an explicit `insta project link` may replace a link.
136
+ if (r?.foreign)
137
+ die(foreignLinkMessage(r.foreign));
138
+ if (r)
139
+ return r.link;
128
140
  if (agentMode())
129
141
  die('agent mode requires a linked project — run `insta setup agent --project <id>`');
142
+ if (deps.autoResolve)
143
+ return deps.autoResolve();
130
144
  // One command, just works: unlinked ≠ error. Resolve the project (auto when there's one,
131
145
  // one-keystroke picker when several) and persist the choice so this happens once per dir.
132
146
  const api = await ApiClient.load();
@@ -137,10 +151,16 @@ export async function requireProject() {
137
151
  listProjects: async () => (await api.request('GET', `/orgs/${orgId}/projects`)).projects,
138
152
  promptChoice,
139
153
  save: async (c) => {
140
- await writeProject(c);
141
154
  // stderr: this is a diagnostic that can precede ANY command's output — under --json,
142
155
  // stdout must stay one parseable document.
143
- process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`);
156
+ if (await persistAutoLink(c, deps.cwd)) {
157
+ process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`);
158
+ }
159
+ else {
160
+ // The home directory never holds a link (~/.insta is the global config): use the choice
161
+ // for this command instead of failing it after the picker has already run.
162
+ process.stderr.write(`using project ${c.projectId} for this command — not saving a link in the home directory; run inside a project directory to remember it\n`);
163
+ }
144
164
  },
145
165
  tty: !!process.stdin.isTTY && !!process.stderr.isTTY,
146
166
  });
@@ -14,17 +14,20 @@ export async function resolveOrgId(opts) {
14
14
  export function billingLines(s, org) {
15
15
  const t = s.totals;
16
16
  const lines = [
17
- `tier: ${s.tier}`,
18
- `status: ${s.billingStatus}`,
17
+ `tier: ${s.tier}`,
18
+ `status: ${s.billingStatus}`,
19
19
  cycleLine(s.window),
20
- `included: $${Number(t.includedUsd).toFixed(2)}`,
21
- `used: $${Number(t.usedUsd).toFixed(4)}`,
22
- `overage: $${Number(t.overageUsd).toFixed(4)}`,
23
- `credits: $${Number(t.creditsUsd).toFixed(2)}`,
24
- `forecast: $${Number(t.forecastUsd).toFixed(4)} (predicted full cycle)`,
20
+ // Two separate figures, never added together: the plan's allowance for this cycle, and the
21
+ // org's wallet. "included usage" is the name the console and the pricing page use for the
22
+ // former; "credits" now means the wallet alone.
23
+ `included usage: $${Number(t.includedUsd).toFixed(2)}`,
24
+ `used: $${Number(t.usedUsd).toFixed(4)}`,
25
+ `overage: $${Number(t.overageUsd).toFixed(4)}`,
26
+ `credits: $${Number(t.creditBalanceUsd).toFixed(2)}`,
27
+ `forecast: $${Number(t.forecastUsd).toFixed(4)} (predicted full cycle)`,
25
28
  ];
26
29
  if (s.subscriptionStatus)
27
- lines.push(`subscription: ${s.subscriptionStatus}`);
30
+ lines.push(`subscription: ${s.subscriptionStatus}`);
28
31
  if (s.billingStatus === 'suspended') {
29
32
  // Four causes, five messages, and every one is a dead end for the others. Tier first: only a
30
33
  // free org can spend a prepaid wallet, and waiting for the next cycle genuinely fixes that one.
@@ -1,5 +1,5 @@
1
1
  import { ApiClient, requireProject } from '../api.js';
2
- import { writeProject } from '../config.js';
2
+ import { isHomeLinkTarget, writeProject } from '../config.js';
3
3
  import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
4
4
  export async function branchCreate(name, opts) {
5
5
  const api = await ApiClient.load();
@@ -20,6 +20,11 @@ export async function branchList(opts) {
20
20
  info(`${b.is_default ? '*' : ' '} ${b.name} [${b.status}] ${b.id}`);
21
21
  }
22
22
  export async function branchSwitch(name, opts = {}) {
23
+ // A switch is remembered in ./.insta/project.json, which never lives in the home directory. Say so
24
+ // up front, instead of auto-resolving a project "for this command" and then refusing to save.
25
+ if (await isHomeLinkTarget()) {
26
+ die('can\'t switch branches in the home directory — the branch is remembered in ./.insta/project.json, and ~/.insta is the insta CLI\'s global config. Run this inside a project directory');
27
+ }
23
28
  const api = await ApiClient.load();
24
29
  const p = await requireProject();
25
30
  const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
@@ -155,21 +155,21 @@ export async function buildReport(dirArg, opts, deps) {
155
155
  const envKeys = existsSync(envExample) ? envKeysFromDotEnvExample(readFileSync(envExample, 'utf8')) : [];
156
156
  const checks = [];
157
157
  // Only a Dockerfile IN the directory passes. A nixpacks-generated one is a real plan, but it is
158
- // not a plan `insta deploy <dir>` can execute: that path builds the directory's own Dockerfile and
159
- // dies without one, and the nixpacks lane runs server-side for GitHub-connected repos only. This
160
- // check used to pass on the generated Dockerfile, so a verifier said "deployable" about a
161
- // directory `deploy` refuses — the whole point of the command is to not do that.
158
+ // not one this machine can hand to every target: on a Fly-backed service `insta deploy <dir>`
159
+ // builds the directory's own Dockerfile and dies without one. On insta-compute the gateway runs
160
+ // nixpacks itself, so the same directory ships. The check stays a non-pass because it cannot know
161
+ // the target, and the detail below says both halves rather than promising either.
162
162
  checks.push(dockerfile.source === 'nixpacks'
163
163
  ? {
164
164
  id: 'dockerfile',
165
165
  severity: 'warning',
166
166
  status: 'fail',
167
167
  title: 'Dockerfile',
168
- detail: `${dockerfileDetail} — but \`insta deploy <dir>\` builds the directory's own Dockerfile; the nixpacks lane runs server-side for GitHub-connected repos only`,
168
+ detail: `${dockerfileDetail} — \`insta deploy <dir>\` builds this on the gateway with nixpacks when the service runs on insta-compute, and needs your own Dockerfile on a Fly-backed one`,
169
169
  // NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
170
170
  // .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
171
171
  // directory does not have). The detected commands above are the reusable part.
172
- nextAction: `to deploy this directory, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point; or connect the GitHub repo to the service (\`insta compute connect-repo <owner/repo>\`) to use the nixpacks lane`,
172
+ nextAction: `on insta-compute this deploys as-is (nixpacks builds it on the gateway). On a Fly-backed service, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point — or connect the GitHub repo (\`insta compute connect-repo <owner/repo>\`)`,
173
173
  }
174
174
  : {
175
175
  id: 'dockerfile',
@@ -225,8 +225,9 @@ export function renderReport(r, explain) {
225
225
  const lines = [];
226
226
  lines.push(`plan for ${r.dir}:`);
227
227
  // The builder line is the first thing read (and the thing an agent scrapes), so it carries the
228
- // lane caveat too — "builder: nixpacks" on its own reads as a promise `insta deploy <dir>` breaks.
229
- const lane = r.plan.builder === 'nixpacks' ? ' — GitHub lane only (`insta compute connect-repo`); `insta deploy <dir>` needs a Dockerfile' : '';
228
+ // lane caveat too — "builder: nixpacks" on its own reads as a promise that is only true on some
229
+ // targets. It has to say the SAME thing the detail below it says, or a scrape sees both claims.
230
+ const lane = r.plan.builder === 'nixpacks' ? ' — server-side: insta-compute builds this as-is, a Fly-backed service needs your own Dockerfile' : '';
230
231
  lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
231
232
  if (r.plan.installCommand)
232
233
  lines.push(` install: ${r.plan.installCommand}`);
@@ -3,14 +3,17 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { ApiClient, ApiError, requireProject } from '../api.js';
4
4
  import { info, die, printJson, handleApproval, renderNextActions, CliExit } from '../util.js';
5
5
  import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js';
6
+ import { packDirectory, windowsModeCaveat } from '../pack.js';
7
+ import { deployArchive, uploadArchive } from '../deploy-archive.js';
8
+ import { parsePort } from './services.js';
6
9
  // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
7
10
  // line moves to stderr.
8
11
  const note = (opts) => (opts.json ? (m) => void process.stderr.write(m + '\n') : info);
9
12
  // Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only
10
- // sent when set (plain deploys unchanged).
11
- export function deployRequestBody(image, branch, opts) {
13
+ // sent when set (plain deploys unchanged). Exactly one of image | archive rides along.
14
+ export function deployRequestBody(source, branch, opts) {
12
15
  return {
13
- image,
16
+ image: source.image,
14
17
  branch,
15
18
  group: opts.group,
16
19
  port: opts.port ? Number(opts.port) : undefined,
@@ -18,6 +21,82 @@ export function deployRequestBody(image, branch, opts) {
18
21
  replaceSource: opts.replaceSource ? true : undefined,
19
22
  };
20
23
  }
24
+ // Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
25
+ // its service. A 404 means the platform predates the contract — and because this is a GET, it
26
+ // 404s the same way for a human and an agent, which a POST would not.
27
+ async function discoverLane(api, projectId, branch, opts) {
28
+ const q = new URLSearchParams({ branch, ...(opts.group ? { group: opts.group } : {}) });
29
+ try {
30
+ const res = await api.rawRequest('GET', `/projects/${projectId}/source-build?${q}`);
31
+ // Validated, not cast. Every value other than the four we know silently fell through to the
32
+ // flyctl path below, so a server that grew a fifth lane would send this CLI down the wrong
33
+ // one and fail somewhere unrelated. An unknown answer is the server being ahead of us, and
34
+ // saying that is more useful than guessing.
35
+ const lane = (res.body ?? {});
36
+ const tag = lane.lane ?? '';
37
+ const known = ['flyctl', 'local-docker', 'archive', 'none'];
38
+ if (!known.includes(tag)) {
39
+ die(`this platform answered with a source-build lane this CLI does not know (${JSON.stringify(tag)}) — upgrade with \`insta upgrade\``);
40
+ }
41
+ // The tag alone is not the contract: each branch carries a payload this code then trusts.
42
+ // An `archive` with malformed limits fell back to local defaults, so the CLI would enforce
43
+ // caps the SERVER does not have, and a `none` with no reason died with `undefined`.
44
+ if (tag === 'archive') {
45
+ const l = lane.limits;
46
+ const positive = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
47
+ if (!l || !positive(l.maxArchiveBytes) || !positive(l.maxExtractedBytes) || !positive(l.maxFiles)) {
48
+ die('this platform offered the archive lane without usable size limits — upgrade with `insta upgrade`');
49
+ }
50
+ }
51
+ const reason = lane.reason;
52
+ if (tag === 'none' && (typeof reason !== 'string' || !reason.trim())) {
53
+ die('this platform refused a source build without saying why — upgrade with `insta upgrade`');
54
+ }
55
+ return lane;
56
+ }
57
+ catch (e) {
58
+ if (e instanceof ApiError && e.status === 404) {
59
+ // The route answered and the TARGET is what is missing: say so, or flyctl turns it into "no Dockerfile".
60
+ // Both ways out: the project has several groups and none is named, or it has none at all.
61
+ if (/compute group not found|branch not found/.test(e.message))
62
+ die(`${e.message} — name it with \`--group <name>\` (see \`insta services list\`), or add one: \`insta services add compute <name>\``);
63
+ return { lane: 'legacy' };
64
+ }
65
+ throw e;
66
+ }
67
+ }
68
+ // Turn a source directory into something deployable. Returns null when an approval is pending.
69
+ export async function prepareSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner, upload) {
70
+ const lane = await discoverLane(api, projectId, branch, opts);
71
+ if (lane.lane === 'none')
72
+ die(lane.reason);
73
+ if (lane.lane !== 'archive') {
74
+ // flyctl, local-docker and legacy all end in an image, and all three need a Dockerfile.
75
+ return { image: await buildFromSource(api, projectId, dir, branch, opts, run) };
76
+ }
77
+ const log = note(opts);
78
+ const absDir = resolve(process.cwd(), dir);
79
+ const caveat = windowsModeCaveat();
80
+ if (caveat)
81
+ log(caveat);
82
+ const packed = packDirectory(absDir, lane.limits);
83
+ log(`packed ${dir}: ${packed.files} files, ${packed.archive.length} bytes`);
84
+ const ref = await uploadArchive(api, projectId, packed, branch, opts, upload);
85
+ if (!ref)
86
+ return null;
87
+ log(`deploying ${packed.archive.length} bytes via the gateway (${ref.build.type})`);
88
+ // One gated call enqueues build+deploy as an operation; the wait is ours, one short poll at a
89
+ // time, because a platform request has to answer inside the ALB's 60s while a build runs minutes.
90
+ // A repo-connected service refuses this with a 409 the same way it refuses an image deploy, and
91
+ // the hint that names the FLAG rather than the API field lives here, beside the `/deploy` path.
92
+ const out = await deployArchive(api, projectId, ref, branch, opts, Date.now, undefined, log)
93
+ .catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
94
+ if (!out)
95
+ return null;
96
+ if ('failed' in out)
97
+ die(out.failed);
98
+ return { deployed: out };
99
+ }
21
100
  // A port mismatch is the #1 deploy mistake: the app boots "successfully" but the proxy routes to
22
101
  // the wrong internal port and every request is refused. For source deploys the Dockerfile states
23
102
  // the truth — use its (last) EXPOSE as the default instead of a blind 8080.
@@ -30,10 +109,10 @@ export function dockerfileExposedPort(dockerfile) {
30
109
  }
31
110
  return port;
32
111
  }
33
- // A directory deploy builds the Dockerfile IN the directory — there is no no-Dockerfile lane here.
34
- // The nixpacks (no-Dockerfile) lane is real but server-side: it runs on the build gateway for
35
- // GitHub-connected repos only, and nothing reachable from `insta deploy <dir>` can enter it. So the
36
- // dead-end message names every way forward instead of the bare "add one".
112
+ // This message is for the target that still REQUIRES a Dockerfile: a Fly-backed service, where a
113
+ // directory deploy builds the Dockerfile in the directory and dies without one. On insta-compute
114
+ // the archive lane carries the directory to the gateway and nixpacks builds it, so this dead end is
115
+ // no longer universal. It names every way forward instead of the bare "add one".
37
116
  //
38
117
  // It deliberately does NOT say "save the Dockerfile `insta build --explain` prints": that file is
39
118
  // not standalone — it COPYs `.nixpacks/nixpkgs-<hash>.nix` support files nixpacks writes beside it,
@@ -60,7 +139,16 @@ export async function deploy(dir, opts) {
60
139
  const p = await requireProject();
61
140
  const branch = opts.branch ?? p.branch;
62
141
  const log = note(opts);
63
- let port = opts.port ? Number(opts.port) : undefined;
142
+ // Junk fails here, before a directory is packed and uploaded for a body the platform would only
143
+ // refuse: the same parser every other --port in this CLI runs. `Number()` alone sent NaN as
144
+ // `null` and let 0 or 70000 travel to the server.
145
+ let port;
146
+ try {
147
+ port = opts.port === undefined ? undefined : parsePort(opts.port);
148
+ }
149
+ catch (e) {
150
+ die(`--${e.message}`);
151
+ }
64
152
  if (dir && port === undefined) {
65
153
  const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile');
66
154
  const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined;
@@ -70,14 +158,26 @@ export async function deploy(dir, opts) {
70
158
  }
71
159
  }
72
160
  const effOpts = { ...opts, port: port?.toString() };
73
- const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image;
74
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
161
+ const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image };
162
+ if (!source)
163
+ return; // an approval is pending; the user approves and re-runs
164
+ if ('deployed' in source) {
165
+ // The archive lane's operation already deployed. One output shape whichever lane ran, minus
166
+ // nextActions, which the operation read does not carry.
167
+ const d = source.deployed;
168
+ if (opts.json)
169
+ return printJson({ image: d.image, url: d.url, branch: d.branch, group: d.group, machineId: d.machineId });
170
+ info(`deployed ${d.image} -> ${d.url} (branch ${d.branch}, group ${d.group})`);
171
+ return;
172
+ }
173
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
75
174
  .catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
76
175
  if (handleApproval(res, opts.json))
77
176
  return;
177
+ const what = source.image;
78
178
  if (opts.json)
79
- return printJson({ image, ...res.body });
80
- info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
179
+ return printJson({ image: source.image, ...res.body });
180
+ info(`deployed ${what} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
81
181
  renderNextActions(res.body.nextActions);
82
182
  }
83
183
  // The platform refuses an image deploy onto a repo-connected service and names the body field it
@@ -4,7 +4,7 @@
4
4
  // server, the platform, the skills, the docs. Never for problems in the app the user is building.
5
5
  //
6
6
  // The backend is InstaCloud dogfooding itself: the "InstaCloud Agent Feedback" project runs the
7
- // ingest service (InsForge/insta-feedback repo) on a postgres + compute pair. It is NOT the
7
+ // ingest service (InsForge/instacloud-feedback repo) on a postgres + compute pair. It is NOT the
8
8
  // control-plane API on purpose — feedback must work logged-out, unlinked, and from insta-oss,
9
9
  // and a control-plane outage is exactly when we most want reports to still arrive.
10
10
  import { readFileSync, statSync } from 'node:fs';
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { agentMode, setupProjectAgentSession } from '../agent.js';
3
3
  import { ApiClient, requireProject } from '../api.js';
4
- import { writeProject } from '../config.js';
4
+ import { HOME_LINK_REFUSAL, isHomeLinkTarget, writeProject } from '../config.js';
5
5
  import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
6
6
  import { installObserve } from '../observe/install.js';
7
7
  import { installRoot } from './observe.js';
@@ -73,6 +73,10 @@ export async function projectCreate(name, opts) {
73
73
  info(' (or just ask your coding agent — it has the insta skill and will do this for you)');
74
74
  return;
75
75
  }
76
+ // Refuse the home directory BEFORE provisioning: refusing only inside writeProject created the
77
+ // project on the control plane and then failed, with no id printed under --json.
78
+ if (await isHomeLinkTarget())
79
+ die(HOME_LINK_REFUSAL);
76
80
  const api = await ApiClient.load();
77
81
  const orgId = await resolveOrg(api, opts.org);
78
82
  const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved });
@@ -103,6 +107,11 @@ export async function projectList(opts) {
103
107
  info(`${p.id} ${p.name} [${p.status}]`);
104
108
  }
105
109
  export async function projectLink(id, opts = {}) {
110
+ // Refuse the home directory BEFORE anything with side effects. In agent mode the session below is
111
+ // saved, and .gitignore edited, at the link root; refusing only inside writeProject left both
112
+ // behind in ~ after the command had already failed.
113
+ if (await isHomeLinkTarget())
114
+ die(HOME_LINK_REFUSAL);
106
115
  const api = await ApiClient.load();
107
116
  if (agentMode())
108
117
  await setupProjectAgentSession(api, id);
@@ -24,7 +24,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
24
  import { readGlobal, writeGlobal } from '../config.js';
25
25
  import { resolveSpawnable } from '../spawn.js';
26
26
  import { info } from '../util.js';
27
- const INSTALL_SH = 'https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh';
27
+ const INSTALL_SH = 'https://raw.githubusercontent.com/InsForge/instacloud-cli/main/install.sh';
28
28
  // The dist-tag document is the authoritative, tiny (~50 byte) answer for `latest`. The full
29
29
  // `latest` manifest is the fallback if that route is ever unavailable.
30
30
  const REGISTRY_DIST_TAGS = 'https://registry.npmjs.org/-/package/insta/dist-tags';