insta 0.0.77 → 0.0.78

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.
@@ -750,7 +750,7 @@ import { homedir } from 'node:os';
750
750
  import { dirname, join } from 'node:path';
751
751
  import { execFileSync } from 'node:child_process';
752
752
  import { createHash, randomUUID } from 'node:crypto';
753
- import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, } from './ssh-config.js';
753
+ import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, ownedBlock, ownedBlockIsFirst } from './ssh-config.js';
754
754
  /** Where this CLI keeps its own SSH material. Deliberately NOT ~/.ssh: we never
755
755
  * touch a key the user already had, and a dedicated key pairs with
756
756
  * IdentitiesOnly to avoid being identified by the wrong one. */
@@ -769,6 +769,15 @@ export const instaAliasStorePath = () => join(instaSSHDir(), 'aliases.json');
769
769
  export const userKnownHostsPath = () => join(homedir(), '.ssh', 'known_hosts');
770
770
  /** The renewal-hook command prefix. ssh-config.ts appends the validated alias. */
771
771
  export const ENSURE_CERT_COMMAND = 'insta __ssh-ensure-cert';
772
+ /** The gateway's public port when the plane does not say: :2222, the port the
773
+ * lane shipped on (:22 waits for a compliance exception). The plane's answer
774
+ * in a mint response ALWAYS wins over this; it exists for stores and planes
775
+ * that predate the `port` field, so a working alias does not stop working. */
776
+ export const DEFAULT_SSH_PORT = 2222;
777
+ /** A TCP port as the plane returns it: an integer, 1..65535. */
778
+ export function isValidPort(v) {
779
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 65535;
780
+ }
772
781
  /** Never throws: a missing or hand-mangled store must degrade to "nothing is
773
782
  * set up", not break `ssh` for every alias. */
774
783
  export function readAliasStore(path = instaAliasStorePath()) {
@@ -800,6 +809,7 @@ export function isValidAliasRecord(r) {
800
809
  return typeof v.projectId === 'string' && v.projectId !== ''
801
810
  && typeof v.serviceId === 'string' && v.serviceId !== ''
802
811
  && (v.branch === undefined || typeof v.branch === 'string')
812
+ && (v.port === undefined || isValidPort(v.port))
803
813
  && isSafeConfigValue(v.host) && isSafeConfigValue(v.username);
804
814
  }
805
815
  /** Refuse to point an existing alias at a different service.
@@ -839,7 +849,7 @@ export function hostEntries(store) {
839
849
  // sit where the rendering does.
840
850
  .filter(([alias, r]) => isSafeAlias(alias) && isValidAliasRecord(r))
841
851
  .sort(([a], [b]) => a.localeCompare(b))
842
- .map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, certificateFile: instaCertPath(alias) }));
852
+ .map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, port: r.port ?? DEFAULT_SSH_PORT, certificateFile: instaCertPath(alias) }));
843
853
  }
844
854
  /**
845
855
  * When `ssh-keygen -L` output says the certificate stops being valid, or
@@ -969,6 +979,11 @@ export function validateCertResponse(body) {
969
979
  // are refused rather than rendered.
970
980
  if (!isSafeTimestamp(b.expiresAt))
971
981
  throw new Error('the platform returned an unusable certificate expiry');
982
+ // Optional only because planes predating the field exist; PRESENT and wrong
983
+ // is refused, never coerced -- a port we made up is a connection that hangs.
984
+ if (b.port !== undefined && !isValidPort(b.port)) {
985
+ throw new Error(`the platform returned an unusable ssh port: ${JSON.stringify(String(b.port).slice(0, 16))}`);
986
+ }
972
987
  // Parsed here rather than at install time so a malformed key fails before
973
988
  // anything is written, instead of after the alias is already recorded.
974
989
  if (b.caPublicKey !== undefined)
@@ -1235,6 +1250,40 @@ function withLockedFile(name, fn, { waitMs, staleMs, sleep, busy }) {
1235
1250
  sleep(LOCK_POLL_MS);
1236
1251
  }
1237
1252
  }
1253
+ /** The block ssh_config SHOULD carry for this store, rendered the one way. */
1254
+ function renderInstalledBlock(store) {
1255
+ return renderConfigBlock({
1256
+ entries: hostEntries(store),
1257
+ identityFile: instaKeyPath(),
1258
+ knownHostsFile: userKnownHostsPath(),
1259
+ ensureCertCommand: ENSURE_CERT_COMMAND,
1260
+ });
1261
+ }
1262
+ /** Whether the installed block differs from what the store renders -- the ONE
1263
+ * predicate for "rewrite ssh_config". A host move, a port the plane changed,
1264
+ * and a stanza written before the Port line existed all show up here; a
1265
+ * renewal whose response changed nothing does not, which matters because
1266
+ * OpenSSH is reading the file while the hook runs and a rename over an open
1267
+ * file fails on Windows. No installed block = nothing to repair (setup owns
1268
+ * the first write). */
1269
+ function configBlockStale(store) {
1270
+ let existing;
1271
+ try {
1272
+ existing = readFileSync(join(homedir(), '.ssh', 'config'), 'utf8');
1273
+ }
1274
+ catch {
1275
+ return false;
1276
+ }
1277
+ const installed = ownedBlock(existing);
1278
+ if (installed === undefined)
1279
+ return false;
1280
+ // Position is content too: a block that has slid below other configuration
1281
+ // is shadowed keyword by keyword (first obtained value wins), whatever its
1282
+ // text says. The rewrite puts it back where setup wrote it, at the top.
1283
+ if (!ownedBlockIsFirst(existing))
1284
+ return true;
1285
+ return installed.replace(/\n+$/, '') !== renderInstalledBlock(store).replace(/\n+$/, '');
1286
+ }
1238
1287
  function installConfigBlock(store) {
1239
1288
  const cfg = join(homedir(), '.ssh', 'config');
1240
1289
  mkdirSync(dirname(cfg), { recursive: true, mode: 0o700 });
@@ -1246,12 +1295,7 @@ function installConfigBlock(store) {
1246
1295
  // target says.
1247
1296
  const existedBefore = pathExists(cfg);
1248
1297
  const existing = readUserText(cfg);
1249
- const block = renderConfigBlock({
1250
- entries: hostEntries(store),
1251
- identityFile: instaKeyPath(),
1252
- knownHostsFile: userKnownHostsPath(),
1253
- ensureCertCommand: ENSURE_CERT_COMMAND,
1254
- });
1298
+ const block = renderInstalledBlock(store);
1255
1299
  // Backed up: this is the file that decides whether the user can ssh anywhere
1256
1300
  // at all, and our block goes at the TOP of it.
1257
1301
  writeFileAtomicSync(cfg, upsertConfigBlock(existing, block), { mode: 0o600, backup: true });
@@ -1370,6 +1414,20 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1370
1414
  try {
1371
1415
  if (!isSafeAlias(alias))
1372
1416
  return;
1417
+ // Repair BEFORE the renewal gate, because the stanza can lag the store
1418
+ // with no certificate due: an alias set up before the Port line existed
1419
+ // dials :22 on every connection, and waiting for its certificate to age
1420
+ // would leave it failing for up to the certificate's lifetime. The
1421
+ // predicate is content drift, so the common case -- an up-to-date stanza --
1422
+ // is one file read and a string compare, with no lock taken; only a stale
1423
+ // stanza takes the alias-store lock, re-checks under it and rewrites.
1424
+ if (configBlockStale(readAliasStore())) {
1425
+ withAliasStoreLock(() => {
1426
+ const store = readAliasStore();
1427
+ if (store[alias] && configBlockStale(store))
1428
+ installConfigBlock(store);
1429
+ }, { waitMs: KNOWN_HOSTS_LOCK_WAIT_MS });
1430
+ }
1373
1431
  if (!certNeedsRenewal(instaCertPath(alias)))
1374
1432
  return;
1375
1433
  // An IDE opens several connections at once and `scp` adds more, so the
@@ -1433,9 +1491,16 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1433
1491
  return;
1434
1492
  if (!certNeedsRenewal(instaCertPath(alias)))
1435
1493
  return;
1494
+ // The plane's answer wins, on every field it answers. `moved` is the
1495
+ // host/user half and keeps its CA requirement below (a new host needs
1496
+ // an anchor); the port has no such requirement but is part of the
1497
+ // record all the same, and a legacy record without one gains it here
1498
+ // so the stanza can carry the line.
1499
+ const port = out.port ?? held.port ?? DEFAULT_SSH_PORT;
1436
1500
  const moved = held.host !== out.host || held.username !== out.username;
1437
- const store = moved
1438
- ? { ...before, [alias]: { ...held, host: out.host, username: out.username } }
1501
+ const recordChanged = moved || held.port !== port;
1502
+ const store = recordChanged
1503
+ ? { ...before, [alias]: { ...held, host: out.host, username: out.username, port } }
1439
1504
  : before;
1440
1505
  const installed = configBlockInstalled();
1441
1506
  const ca = out.caPublicKey;
@@ -1463,11 +1528,13 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1463
1528
  // host into a renewal that gives up -- one failed renewal and a
1464
1529
  // `--setup` to repair it, never a half-moved alias.
1465
1530
  const steps = [];
1466
- if (moved)
1531
+ if (recordChanged)
1467
1532
  steps.push(() => { const back = snapshotForUndo(instaAliasStorePath()); writeAliasStore(store); return back; });
1468
1533
  if (ca !== undefined)
1469
1534
  steps.push(() => installCertAuthority(hostPatternFor(out.host), ca));
1470
- if (moved && installed)
1535
+ // Drift, not "moved": a changed port rewrites the stanza; an unchanged
1536
+ // response leaves the file alone (see configBlockStale).
1537
+ if (installed && configBlockStale(store))
1471
1538
  steps.push(() => installConfigBlock(store));
1472
1539
  steps.push(() => out.staged.commit());
1473
1540
  // ONE section from reading the anchor to committing the certificate
@@ -1752,7 +1819,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
1752
1819
  assertAliasFree(before, alias, { projectId: p.projectId, serviceId: svc.id, branch });
1753
1820
  const store = {
1754
1821
  ...before,
1755
- [alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username },
1822
+ [alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT },
1756
1823
  };
1757
1824
  // Whether ~/.ssh is BACKING this store, not merely whether --setup was
1758
1825
  // passed. The store and the certificate are rewritten by every issuance,
@@ -1811,9 +1878,9 @@ export async function computeSSH(serviceName, opts, deps = {}) {
1811
1878
  out.staged.discard();
1812
1879
  }
1813
1880
  if (opts.json)
1814
- return printJson({ alias, host: out.host, username: out.username, expiresAt: out.expiresAt, configured: installed });
1881
+ return printJson({ alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, configured: installed });
1815
1882
  for (const line of sshAdvice({
1816
- alias, host: out.host, username: out.username, expiresAt: out.expiresAt, serviceName: svc.name,
1883
+ alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, serviceName: svc.name,
1817
1884
  configured: installed, identityFile: instaKeyPath(), certificateFile: instaCertPath(alias),
1818
1885
  }))
1819
1886
  emit(line);
@@ -1830,7 +1897,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
1830
1897
  */
1831
1898
  export function sshAdvice(r) {
1832
1899
  const head = r.configured
1833
- ? [`ssh ${r.alias} → ${r.username}@${r.host}`]
1900
+ ? [`ssh ${r.alias} → ${r.username}@${r.host} (port ${r.port})`]
1834
1901
  : [
1835
1902
  // Every option here is load-bearing. The key lives at
1836
1903
  // ~/.insta/ssh/id_ed25519 and the certificate at <alias>-cert.pub;
@@ -1839,7 +1906,9 @@ export function sshAdvice(r) {
1839
1906
  // this command just issued -- it fails, having printed success.
1840
1907
  // IdentitiesOnly stops a loaded agent from spending the server's
1841
1908
  // MaxAuthTries on unrelated keys before ours is ever tried.
1842
- `ssh -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
1909
+ // -p is not optional: the gateway is on :2222 and :22 is closed, so a
1910
+ // pasted command without it fails before the credential is ever tried.
1911
+ `ssh -p ${r.port} -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
1843
1912
  ` run \`insta compute ssh ${r.serviceName} --setup\` once for the shorter \`ssh ${r.alias}\`, automatic renewal, and scp/-L support`,
1844
1913
  ];
1845
1914
  return [...head, ` certificate valid until ${r.expiresAt}`];
@@ -162,27 +162,66 @@ async function readStdin() {
162
162
  data += chunk;
163
163
  return data.trim();
164
164
  }
165
+ // Every platform SkipReason (applyTargets.ts) except 'unknown-service', which means the plan
166
+ // could not resolve the service, not that it correctly chose to skip it.
167
+ const BENIGN_SKIP_REASONS = new Set(['no-image', 'other-branch', 'caller-deploying', 'skip-deploy']);
168
+ // Design doc §5's three outcomes: an unwritten entry is a hard failure; a written entry with a
169
+ // failed or unexplained-skip service is durable but not fully live; anything else is success.
170
+ export function applyVerdict(entries, services) {
171
+ if (entries.some((e) => !e.written))
172
+ return 'not-written';
173
+ const degraded = services.some((s) => s.result === 'failed' || (s.result === 'skipped' && !BENIGN_SKIP_REASONS.has(s.reason ?? '')));
174
+ return degraded ? 'degraded' : 'ok';
175
+ }
176
+ // util.ts already claims 1 (plain failure) and 2 (nothing ran, re-run is safe); this state fits
177
+ // neither, so it takes the next code rather than overload one of theirs.
178
+ export function applyExitCode(verdict) {
179
+ return verdict === 'ok' ? 0 : verdict === 'not-written' ? 1 : 3;
180
+ }
181
+ // Pure — one line per service, extending `branch.ts`'s create/skip convention; `started` matters most, it means new billing.
182
+ export function applyServiceLines(services) {
183
+ return services.map((s) => {
184
+ if (s.result === 'started')
185
+ return ` + ${s.serviceId} started (was stopped — this now bills)`;
186
+ if (s.result === 'deployed')
187
+ return ` ~ ${s.serviceId} redeployed`;
188
+ if (s.result === 'failed')
189
+ return ` ! ${s.serviceId} failed${s.reason ? `: ${s.reason}` : ''}`;
190
+ return ` = ${s.serviceId}${s.reason ? ` (${s.reason})` : ''}`;
191
+ });
192
+ }
165
193
  // Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
166
194
  // it to a branch service instead, which implies the current branch (binding requires one). Value
167
195
  // comes from the argument, or stdin when omitted (keeps secret values out of shell history).
168
- export async function secretsSet(name, value, opts) {
196
+ export async function secretsSet(name, value, opts, deps) {
169
197
  // An empty --service must not fall through to a project-wide WRITE. The scoping test below is a
170
198
  // truthiness check, so `--service ''` (a client interpolating an absent variable) would have put
171
199
  // the secret at a WIDER scope than the caller asked for, visible to every service on the branch.
172
200
  assertServiceRef(opts.service);
173
- const api = await ApiClient.load();
174
- const p = await requireProject();
201
+ const d = deps ?? (await loadDeps());
175
202
  const v = value ?? (await readStdin());
176
203
  if (!v)
177
204
  die('value is required (pass as an argument or on stdin)');
178
- const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch;
179
- const payload = { value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
180
- const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload);
205
+ const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
206
+ // The batch always deploys on ONE branch, so the top level falls back to the linked one even when the entry itself is project-wide.
207
+ const entry = { kind: 'set', name, value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
208
+ const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
181
209
  if (handleApproval(res, opts.json))
182
210
  return;
211
+ const entries = res.body.entries ?? [];
212
+ const services = res.body.services ?? [];
213
+ const verdict = applyVerdict(entries, services);
214
+ process.exitCode = applyExitCode(verdict);
183
215
  if (opts.json)
184
- return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
216
+ return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
185
217
  info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
218
+ for (const line of applyServiceLines(services))
219
+ info(line);
220
+ // Verdict note on stderr, after the per-service lines: not success, and not the same failure either.
221
+ if (verdict === 'not-written')
222
+ process.stderr.write(`error: ${name} was not written\n`);
223
+ else if (verdict === 'degraded')
224
+ process.stderr.write(`warning: ${name} is saved but not applied everywhere — retry the deploy for the services marked failed above, do not resend this value\n`);
186
225
  }
187
226
  // Remove a user secret. --service removes only THAT service's copy (the platform has always
188
227
  // honoured ?service= here; without the flag a name several services define stays defined).
@@ -192,21 +231,26 @@ export async function secretsUnset(name, opts, deps) {
192
231
  // Service scoping REQUIRES a branch (a service exists on a branch, so the platform rejects the
193
232
  // pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
194
233
  const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
195
- const parts = [];
196
- if (branch)
197
- parts.push(`branch=${encodeURIComponent(branch)}`);
198
- if (opts.service)
199
- parts.push(`service=${encodeURIComponent(opts.service)}`);
200
- const qs = parts.length ? `?${parts.join('&')}` : '';
201
- const res = await d.api.rawRequest('DELETE', `/projects/${d.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
234
+ const entry = { kind: 'delete', name, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
235
+ const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
202
236
  if (handleApproval(res, opts.json))
203
237
  return;
238
+ const entries = res.body.entries ?? [];
239
+ const services = res.body.services ?? [];
240
+ const verdict = applyVerdict(entries, services);
241
+ process.exitCode = applyExitCode(verdict);
204
242
  // The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
205
243
  // is the linked branch's, and the output has to say which scope it actually touched.
206
244
  if (opts.json)
207
- return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
245
+ return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
208
246
  const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
209
247
  info(`unset ${name} (${scope})`);
248
+ for (const line of applyServiceLines(services))
249
+ info(line);
250
+ if (verdict === 'not-written')
251
+ process.stderr.write(`error: ${name} was not removed\n`);
252
+ else if (verdict === 'degraded')
253
+ process.stderr.write(`warning: ${name} is removed but not applied everywhere — retry the deploy for the services marked failed above\n`);
210
254
  }
211
255
  export async function secretsBind(envName, source, opts) {
212
256
  if (!opts.to)
@@ -115,10 +115,12 @@ export function renderConfigBlock(o) {
115
115
  throw new Error(`refusing to write an unsafe ssh HostName into ssh_config: ${JSON.stringify(e.hostName)}`);
116
116
  if (!isSafeConfigValue(e.user))
117
117
  throw new Error(`refusing to write an unsafe ssh User into ssh_config: ${JSON.stringify(e.user)}`);
118
+ if (!Number.isInteger(e.port) || e.port < 1 || e.port > 65535)
119
+ throw new Error(`refusing to write an unusable ssh Port into ssh_config: ${JSON.stringify(e.port)}`);
118
120
  lines.push(`Host ${e.alias}`,
119
121
  // Without HostName and User the alias is not routing at all: ssh resolves
120
122
  // `api.insta` in DNS and logs in as the local OS username.
121
- ` HostName ${e.hostName}`, ` User ${e.user}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
123
+ ` HostName ${e.hostName}`, ` User ${e.user}`, ` Port ${e.port}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
122
124
  // WRITTEN, not left to the default, and this is the one keyword where
123
125
  // being first in the file does not save us. First-wins settles a keyword
124
126
  // two blocks both set; a keyword we never set at all goes on being filled
@@ -210,6 +212,35 @@ export function upsertConfigBlock(existing, block) {
210
212
  export function hasOwnedBlock(existing) {
211
213
  return existing.split('\n').some(isMarkerLine(BLOCK_BEGIN));
212
214
  }
215
+ /** Whether nothing that OpenSSH would read precedes the owned block. OpenSSH
216
+ * takes the FIRST obtained value for each keyword, so a `Host *` stanza -- or a
217
+ * bare global `Port 22` -- above our block silently overrides the block's
218
+ * Port, HostName, User and credential; that is why upsertConfigBlock writes
219
+ * the block at the top. Comments and blank lines above it are harmless and do
220
+ * not count. false when there is no owned block at all. */
221
+ export function ownedBlockIsFirst(existing) {
222
+ const lines = existing.split('\n');
223
+ const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
224
+ if (begin === -1)
225
+ return false;
226
+ return lines.slice(0, begin).every((l) => /^\s*(#.*)?$/.test(l));
227
+ }
228
+ /** The installed owned block, BEGIN through END marker inclusive, exactly as it
229
+ * sits in the file; undefined when there is none (or an unterminated one). It
230
+ * exists so a writer can compare what IS installed with what it WOULD render
231
+ * and touch the file only when the two differ -- a stanza written before a
232
+ * keyword existed (the Port line) is the case, and "nothing changed in the
233
+ * response" is not the same question as "nothing would change in the file". */
234
+ export function ownedBlock(existing) {
235
+ const lines = existing.split('\n');
236
+ const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
237
+ if (begin === -1)
238
+ return undefined;
239
+ const end = lines.findIndex((l, n) => n > begin && isMarkerLine(BLOCK_END)(l));
240
+ if (end === -1)
241
+ return undefined;
242
+ return lines.slice(begin, end + 1).join('\n');
243
+ }
213
244
  /** A marker is a WHOLE LINE, never a substring of one.
214
245
  *
215
246
  * Matching the marker text wherever it occurred made a user's comment that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.77",
3
+ "version": "0.0.78",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [