insta 0.0.59 → 0.0.61
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/commands/observe.js +24 -11
- package/dist/commands/project.js +7 -4
- package/dist/commands/template.js +29 -8
- package/dist/ensure-skills.js +7 -5
- package/dist/github-source.js +316 -0
- package/dist/index.js +7 -7
- package/dist/observe/install.js +4 -1
- package/package.json +1 -1
package/dist/commands/observe.js
CHANGED
|
@@ -11,24 +11,36 @@ import { renderReport } from '../observe/report.js';
|
|
|
11
11
|
import { ApiClient, requireProject } from '../api.js';
|
|
12
12
|
import { info, printJson } from '../util.js';
|
|
13
13
|
// Where the audit lives: the hook records at the directory it is materialized in
|
|
14
|
-
// (<root>/.insta/observe/hook.js — see projectRootFor), so report/sync must anchor on
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// (<root>/.insta/observe/hook.js — see projectRootFor), so report/sync must anchor on exactly
|
|
15
|
+
// that: the NEAREST materialized hook above cwd, which is what the Codex wrapper and the Claude
|
|
16
|
+
// entry run. The link file is only a fallback for a directory where no hook exists yet (it is
|
|
17
|
+
// where the next install will land), then cwd (→ "audit log is empty"). Preferring the link file
|
|
18
|
+
// would break the moment the two diverge — e.g. a hook materialized below a linked parent —
|
|
19
|
+
// because the reader would look where the writer never writes.
|
|
18
20
|
export async function auditRoot(cwd = process.cwd()) {
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
return
|
|
21
|
+
const hook = nearestHookRoot(cwd);
|
|
22
|
+
if (hook)
|
|
23
|
+
return hook;
|
|
24
|
+
return (await findProjectRoot(cwd)) ?? cwd;
|
|
25
|
+
}
|
|
26
|
+
/** Nearest ancestor (or cwd) holding a materialized hook, or null. */
|
|
27
|
+
export function nearestHookRoot(cwd) {
|
|
22
28
|
let dir = resolve(cwd);
|
|
23
29
|
for (;;) {
|
|
24
30
|
if (existsSync(join(dir, '.insta', 'observe', 'hook.js')))
|
|
25
31
|
return dir;
|
|
26
32
|
const parent = dirname(dir);
|
|
27
33
|
if (parent === dir)
|
|
28
|
-
return
|
|
34
|
+
return null;
|
|
29
35
|
dir = parent;
|
|
30
36
|
}
|
|
31
37
|
}
|
|
38
|
+
/** Where `install` materializes: the linked project root when inside one (so a re-link or
|
|
39
|
+
* `observe install` from a subdirectory refreshes the project's hook instead of minting a second
|
|
40
|
+
* one the readers never look at — same rule as writeProject), else cwd. */
|
|
41
|
+
export async function installRoot(cwd = process.cwd()) {
|
|
42
|
+
return (await findProjectRoot(cwd)) ?? cwd;
|
|
43
|
+
}
|
|
32
44
|
async function readAudit() {
|
|
33
45
|
try {
|
|
34
46
|
const txt = await readFile(join(await auditRoot(), '.insta', 'audit.jsonl'), 'utf8');
|
|
@@ -46,8 +58,9 @@ function* chunk(a, n) {
|
|
|
46
58
|
yield a.slice(i, i + n);
|
|
47
59
|
}
|
|
48
60
|
export async function observeInstall() {
|
|
49
|
-
const
|
|
50
|
-
|
|
61
|
+
const root = await installRoot();
|
|
62
|
+
const res = installObserve({ cwd: root });
|
|
63
|
+
info(`installed observe hook (claude: ${res.claude}, codex: ${res.codex}) → ${join(root, '.insta', 'observe')}`);
|
|
51
64
|
if (res.ignored.length)
|
|
52
65
|
info(` .gitignore += ${res.ignored.join(', ')}`);
|
|
53
66
|
const hint = untrackHint(res.tracked);
|
|
@@ -57,7 +70,7 @@ export async function observeInstall() {
|
|
|
57
70
|
info('run `insta observe report` to review, `insta observe sync` to upload to the project timeline');
|
|
58
71
|
}
|
|
59
72
|
export async function observeUninstall() {
|
|
60
|
-
uninstallObserve(
|
|
73
|
+
uninstallObserve(await installRoot());
|
|
61
74
|
info('uninstalled observe hook');
|
|
62
75
|
}
|
|
63
76
|
export async function observeReport(opts) {
|
package/dist/commands/project.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ApiClient, requireProject } from '../api.js';
|
|
|
3
3
|
import { writeProject } from '../config.js';
|
|
4
4
|
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
|
|
5
5
|
import { installObserve } from '../observe/install.js';
|
|
6
|
+
import { installRoot } from './observe.js';
|
|
6
7
|
import { untrackHint } from '../gitignore.js';
|
|
7
8
|
import { installSkills } from '../ensure-skills.js';
|
|
8
9
|
// Generic directory names that make a useless project name ("projects", "~", "tmp", …). When the
|
|
@@ -14,10 +15,12 @@ const GENERIC_DIRS = new Set([
|
|
|
14
15
|
'app', 'apps', 'users', 'user', 'bin', 'new', 'test', 'tests',
|
|
15
16
|
]);
|
|
16
17
|
// Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
|
|
18
|
+
// Anchored at the linked project root (writeProject just updated it), not cwd: a re-link from a
|
|
19
|
+
// subdirectory must refresh the project's hook, not mint a second one the readers never see.
|
|
17
20
|
// quiet: with --json the install still runs, but its note moves to stderr (stdout is JSON-only).
|
|
18
|
-
function tryInstallObserve(quiet = false) {
|
|
21
|
+
async function tryInstallObserve(quiet = false) {
|
|
19
22
|
try {
|
|
20
|
-
const r = installObserve({ cwd:
|
|
23
|
+
const r = installObserve({ cwd: await installRoot() });
|
|
21
24
|
const say = (line) => (quiet ? process.stderr.write(line + '\n') : info(line));
|
|
22
25
|
if (r.claude || r.codex)
|
|
23
26
|
say(' installed observe hook (credential audit) → ./.insta/observe');
|
|
@@ -82,7 +85,7 @@ export async function projectCreate(name, opts) {
|
|
|
82
85
|
info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`);
|
|
83
86
|
renderNextActions(out.nextActions);
|
|
84
87
|
}
|
|
85
|
-
tryInstallObserve(opts.json);
|
|
88
|
+
await tryInstallObserve(opts.json);
|
|
86
89
|
await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
|
|
87
90
|
}
|
|
88
91
|
export async function projectList(opts) {
|
|
@@ -104,7 +107,7 @@ export async function projectLink(id, opts = {}) {
|
|
|
104
107
|
printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } });
|
|
105
108
|
else
|
|
106
109
|
info(`linked project ${project.id} (${project.name})`);
|
|
107
|
-
tryInstallObserve(opts.json);
|
|
110
|
+
await tryInstallObserve(opts.json);
|
|
108
111
|
await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
|
|
109
112
|
}
|
|
110
113
|
export async function projectDelete(opts) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `insta template` — browse the platform template registry and deploy a template (by registry
|
|
2
|
-
// code,
|
|
2
|
+
// code, from a local directory carrying insta.template.yaml, or from a github.com URL whose
|
|
3
|
+
// manifest github-source.ts fetches with the user's own git credentials) onto a branch. The deploy is a
|
|
3
4
|
// platform-side pipeline (create services → write variables → deploy → health check); the CLI
|
|
4
5
|
// submits it and renders progress by polling the deployment resource.
|
|
5
6
|
import { join, resolve } from 'node:path';
|
|
@@ -9,6 +10,7 @@ import * as clack from '@clack/prompts';
|
|
|
9
10
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
10
11
|
import { info, printJson, handleApproval, renderNextActions, CliCancel } from '../util.js';
|
|
11
12
|
import { MANIFEST_FILE, collectManifestVariables, loadTemplateManifest } from '../template-manifest.js';
|
|
13
|
+
import { parseGitHubTemplateUrl, fetchGitHubTemplate } from '../github-source.js';
|
|
12
14
|
// One aligned row per template; numeric columns right-aligned. Plain padded columns, as the rest
|
|
13
15
|
// of the CLI (storage list, compute check-domain) — no table library.
|
|
14
16
|
export function templateListLines(templates) {
|
|
@@ -172,12 +174,18 @@ export function looksLikePath(target) {
|
|
|
172
174
|
// tool should fake.
|
|
173
175
|
const expandHome = (target) => target.replace(/^~(?=[/\\]|$)/, () => homedir());
|
|
174
176
|
/**
|
|
175
|
-
* Which deploy mode a target selects.
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
177
|
+
* Which deploy mode a target selects. A URL is classified FIRST — it contains `/`, which
|
|
178
|
+
* looksLikePath would otherwise claim as a directory, and a non-GitHub URL must be named as
|
|
179
|
+
* unsupported rather than reported as a missing manifest. Then: local mode is OPTED INTO by a
|
|
180
|
+
* path-looking target (./dir, /abs, sub/dir); a bare word is ALWAYS a registry code — even when a
|
|
181
|
+
* same-named directory with a manifest sits in the working directory, deploying it must be
|
|
182
|
+
* explicit (./plausible), never a cwd coincidence. And a path-looking target with no manifest is a
|
|
183
|
+
* mistake, never a registry code.
|
|
179
184
|
*/
|
|
180
185
|
export function deployMode(target, hasManifest = (d) => existsSync(join(d, MANIFEST_FILE))) {
|
|
186
|
+
const github = parseGitHubTemplateUrl(target);
|
|
187
|
+
if (github)
|
|
188
|
+
return { kind: 'github', target: github };
|
|
181
189
|
if (!looksLikePath(target))
|
|
182
190
|
return { kind: 'registry', code: target };
|
|
183
191
|
const dir = resolve(process.cwd(), expandHome(target));
|
|
@@ -314,8 +322,21 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
|
|
|
314
322
|
// code, so a same-named local directory can never shadow the registry template.
|
|
315
323
|
const mode = deployMode(target);
|
|
316
324
|
let manifest;
|
|
325
|
+
let source;
|
|
317
326
|
let vars;
|
|
318
|
-
if (mode.kind === '
|
|
327
|
+
if (mode.kind === 'github') {
|
|
328
|
+
const fetched = await (deps.fetchGitHub ?? ((t) => fetchGitHubTemplate(t)))(mode.target);
|
|
329
|
+
manifest = fetched.manifest;
|
|
330
|
+
source = fetched.source;
|
|
331
|
+
vars = collectManifestVariables(manifest);
|
|
332
|
+
// Spec 4.4: exactly ONE line in front of today's output. A second "deploying template …" line
|
|
333
|
+
// would read as a duplicate of the "deploying template <code> to branch <branch>" line below,
|
|
334
|
+
// so the manifest's code@version rides on this one instead.
|
|
335
|
+
if (!quiet) {
|
|
336
|
+
info(`fetching template ${manifest.code}@${manifest.version} from github.com/${source.repo}@${source.ref}${source.path ? ` (${source.path})` : ''} at ${source.commit.slice(0, 7)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (mode.kind === 'local') {
|
|
319
340
|
manifest = loadTemplateManifest(mode.dir); // parse + local validation (pinned images, described vars)
|
|
320
341
|
vars = collectManifestVariables(manifest);
|
|
321
342
|
if (!quiet)
|
|
@@ -331,7 +352,7 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
|
|
|
331
352
|
const onAutoResolved = quiet ? undefined : (v) => info(` ${v.name}: ${v.generate ? `platform-generated (${v.generate})` : `default (${v.default})`}`);
|
|
332
353
|
const variables = await resolveVariables(vars, given, { tty, ask, onAutoResolved });
|
|
333
354
|
// The endpoint takes the branch NAME directly (branchId is its uuid alias) — no lookup needed.
|
|
334
|
-
const body = { ...(mode.kind === '
|
|
355
|
+
const body = { ...(mode.kind === 'registry' ? { templateCode: mode.code } : { manifest }), branch: branchName, variables };
|
|
335
356
|
let res;
|
|
336
357
|
try {
|
|
337
358
|
res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, body);
|
|
@@ -355,7 +376,7 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
|
|
|
355
376
|
info(`deploying template ${codeLabel} to branch ${branchName} (${deploymentId})`);
|
|
356
377
|
const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`), deploymentId, quiet ? () => { } : info, deps.wait);
|
|
357
378
|
if (opts.json)
|
|
358
|
-
return printJson(dep);
|
|
379
|
+
return printJson(source ? { source, ...dep } : dep);
|
|
359
380
|
info(`template ${codeLabel} deployed to branch ${branchName}`);
|
|
360
381
|
for (const u of deploymentUrls(dep))
|
|
361
382
|
info(` ${u}`);
|
package/dist/ensure-skills.js
CHANGED
|
@@ -91,16 +91,18 @@ export async function installSkills(deps) {
|
|
|
91
91
|
installed++;
|
|
92
92
|
print(r.ok ? ` ${s.label} ✓` : ` ${s.label} failed — add manually: npx ${s.args.join(' ')}`);
|
|
93
93
|
}
|
|
94
|
-
//
|
|
95
|
-
//
|
|
94
|
+
// The already-tracked hint is independent of this run: a repo that committed the skill dirs
|
|
95
|
+
// or the lock months ago needs the `git rm --cached` line whether or not today's adds worked.
|
|
96
|
+
const hint = untrackHint(alreadyTracked(deps.cwd, SKILL_DIRS));
|
|
97
|
+
if (hint)
|
|
98
|
+
print(hint);
|
|
99
|
+
// The ignore entries only when something was actually written: an offline run that failed
|
|
100
|
+
// every add has no new skill dirs or lock, and a `.gitignore +=` line would claim otherwise.
|
|
96
101
|
if (installed === 0)
|
|
97
102
|
return;
|
|
98
103
|
const added = ensureGitignore(deps.cwd, SKILL_DIRS, GITIGNORE_COMMENT);
|
|
99
104
|
if (added.length)
|
|
100
105
|
print(` .gitignore += ${added.join(', ')}`);
|
|
101
|
-
const hint = untrackHint(alreadyTracked(deps.cwd, SKILL_DIRS));
|
|
102
|
-
if (hint)
|
|
103
|
-
print(hint);
|
|
104
106
|
}
|
|
105
107
|
catch {
|
|
106
108
|
/* best-effort convenience — never block the host command */
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Fetch a template manifest out of a GitHub repository, on this machine, with the user's own git
|
|
2
|
+
// credentials. Parsing, ref resolution and the shallow clone live here; the deploy path stays in
|
|
3
|
+
// commands/template.ts. See docs/superpowers/specs/2026-09-04-template-deploy-github-url-design.md.
|
|
4
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
5
|
+
import { mkdtempSync, rmSync, existsSync, realpathSync, statSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, sep } from 'node:path';
|
|
8
|
+
import { loadTemplateManifest, MANIFEST_FILE } from './template-manifest.js';
|
|
9
|
+
const GITHUB_HOST = /^(?:https?:\/\/)?(?:www\.)?github\.com\//i;
|
|
10
|
+
// An explicit address: a scheme, or an scp-style user@host:path. Never a local path.
|
|
11
|
+
const EXPLICIT_ADDRESS = /^[a-z][a-z0-9+.-]*:\/\/|^[^/\\]+@[^/\\]+:/i;
|
|
12
|
+
// Scheme-less first segments we still read as a host. A bare `<name>/<path>` is otherwise a LOCAL
|
|
13
|
+
// PATH: `v1.0/templates` and `my.app/bot` are directories, and reading every dotted first segment
|
|
14
|
+
// as a host broke them. Only names that unambiguously host code belong here.
|
|
15
|
+
const KNOWN_GIT_HOSTS = new Set([
|
|
16
|
+
'gitlab.com', 'www.gitlab.com', 'bitbucket.org', 'www.bitbucket.org', 'gist.github.com',
|
|
17
|
+
'codeberg.org', 'git.sr.ht', 'dev.azure.com', 'ssh.dev.azure.com', 'gitea.com', 'sourceforge.net',
|
|
18
|
+
]);
|
|
19
|
+
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
20
|
+
/** Is this an address rather than a path? Only an explicit scheme/scp form, or a first segment
|
|
21
|
+
* naming a host we recognise. A scheme-less name is far likelier to be someone's directory than
|
|
22
|
+
* a git host, so everything else falls through to local and registry modes. */
|
|
23
|
+
function isAddress(target) {
|
|
24
|
+
if (EXPLICIT_ADDRESS.test(target))
|
|
25
|
+
return true;
|
|
26
|
+
return KNOWN_GIT_HOSTS.has((target.split(/[/\\]/, 1)[0] ?? '').toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
export function unsupportedSourceMessage(target) {
|
|
29
|
+
return `unsupported template source: ${target}. Use a registry code, a local directory, or https://github.com/<owner>/<repo>[/tree/<ref>[/<dir>]]`;
|
|
30
|
+
}
|
|
31
|
+
// Percent-decode first, so %2e%2e and %2f cannot smuggle a traversal past the segment check.
|
|
32
|
+
function decodedSegments(parts, target) {
|
|
33
|
+
return parts.map((raw) => {
|
|
34
|
+
let seg;
|
|
35
|
+
try {
|
|
36
|
+
seg = decodeURIComponent(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
40
|
+
}
|
|
41
|
+
if (!seg || seg === '.' || seg === '..' || /[/\\\0]/.test(seg))
|
|
42
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
43
|
+
return seg;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Parse a github.com URL into owner, repo and the still-unsplit ref+path tail.
|
|
47
|
+
* null = not URL-shaped, so the caller's local-directory and registry modes still get a look.
|
|
48
|
+
* A URL-shaped target that is not a github.com repository URL throws (spec 4.1). */
|
|
49
|
+
export function parseGitHubTemplateUrl(target) {
|
|
50
|
+
if (!GITHUB_HOST.test(target)) {
|
|
51
|
+
// Name a non-GitHub address for what it is; let everything else reach local/registry mode.
|
|
52
|
+
if (isAddress(target))
|
|
53
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// Links copied from GitHub's UI carry ?plain=1, #L1-L5 or ?tab=readme-ov-file, and none of that
|
|
57
|
+
// names a file. A `?` or `#` in a real path arrives percent-encoded, so a bare one is always the
|
|
58
|
+
// delimiter. Dropping it silently beats a "directory" called `bot?tab=readme-ov-file`.
|
|
59
|
+
const clean = target.replace(/[?#][\s\S]*$/, '');
|
|
60
|
+
const rest = clean.replace(GITHUB_HOST, '').replace(/\/+$/, '');
|
|
61
|
+
const parts = rest.split('/');
|
|
62
|
+
const owner = parts[0] ?? '';
|
|
63
|
+
const repo = (parts[1] ?? '').replace(/\.git$/i, '');
|
|
64
|
+
if (!SEGMENT.test(owner) || !SEGMENT.test(repo))
|
|
65
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
66
|
+
const kind = parts[2];
|
|
67
|
+
if (kind === undefined)
|
|
68
|
+
return { owner, repo, refAndPath: '' };
|
|
69
|
+
if (kind !== 'tree' && kind !== 'blob')
|
|
70
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
71
|
+
let tail = decodedSegments(parts.slice(3), target);
|
|
72
|
+
if (!tail.length)
|
|
73
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
74
|
+
if (kind === 'blob') {
|
|
75
|
+
// A file link is read as its directory, and only for the manifest itself.
|
|
76
|
+
if (tail[tail.length - 1] !== MANIFEST_FILE)
|
|
77
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
78
|
+
tail = tail.slice(0, -1);
|
|
79
|
+
if (!tail.length)
|
|
80
|
+
throw new Error(unsupportedSourceMessage(target));
|
|
81
|
+
}
|
|
82
|
+
return { owner, repo, refAndPath: tail.join('/') };
|
|
83
|
+
}
|
|
84
|
+
export const LS_REMOTE_TIMEOUT_MS = 30_000;
|
|
85
|
+
export const CLONE_TIMEOUT_MS = 120_000;
|
|
86
|
+
export function gitMissingMessage() {
|
|
87
|
+
return `git is required to deploy a template from a GitHub URL. Install git, or clone the repository yourself and run: insta template deploy ./<dir>`;
|
|
88
|
+
}
|
|
89
|
+
// git asks for nothing: no terminal credential prompt, no SSH host-key or passphrase prompt (an
|
|
90
|
+
// insteadOf rewrite can send the clone over SSH). The environment stops it asking; only the
|
|
91
|
+
// timeout stops it waiting.
|
|
92
|
+
// GIT_SSH_COMMAND is EXTENDED, never replaced: a user's own `ssh -i <key>` IS how their private
|
|
93
|
+
// repository authenticates, and overwriting it would drop the credentials this feature runs on.
|
|
94
|
+
function nonInteractiveEnv(env = process.env) {
|
|
95
|
+
const ssh = env.GIT_SSH_COMMAND?.trim();
|
|
96
|
+
return {
|
|
97
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
98
|
+
GIT_SSH_COMMAND: ssh ? `${ssh} -o BatchMode=yes` : 'ssh -o BatchMode=yes',
|
|
99
|
+
GCM_INTERACTIVE: 'never',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// git spawns helpers (ssh, a credential manager) that inherit its pipes, so a timeout has to take
|
|
103
|
+
// the whole group. Windows has no process groups; taskkill /T walks the tree instead.
|
|
104
|
+
function killTree(child) {
|
|
105
|
+
try {
|
|
106
|
+
if (process.platform === 'win32' && child.pid) {
|
|
107
|
+
// spawn reports a missing binary ASYNCHRONOUSLY: without this listener an absent taskkill
|
|
108
|
+
// becomes an uncaught ENOENT that kills the CLI, and try/catch never sees it.
|
|
109
|
+
nodeSpawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
110
|
+
.on('error', () => { });
|
|
111
|
+
}
|
|
112
|
+
else if (child.pid) {
|
|
113
|
+
process.kill(-child.pid, 'SIGKILL'); // negative pid addresses the group
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch { /* already gone */ }
|
|
117
|
+
try {
|
|
118
|
+
child.kill('SIGKILL');
|
|
119
|
+
}
|
|
120
|
+
catch { /* already gone */ }
|
|
121
|
+
}
|
|
122
|
+
// A settled promise does not let the CLI exit. Node keeps its event loop alive for an open pipe,
|
|
123
|
+
// and a grandchild that escaped the kill still holds the write end. Releasing our read ends and
|
|
124
|
+
// unref-ing the child is what actually lets the process end.
|
|
125
|
+
function releaseChild(child) {
|
|
126
|
+
try {
|
|
127
|
+
child.stdout?.destroy();
|
|
128
|
+
}
|
|
129
|
+
catch { /* already closed */ }
|
|
130
|
+
try {
|
|
131
|
+
child.stderr?.destroy();
|
|
132
|
+
}
|
|
133
|
+
catch { /* already closed */ }
|
|
134
|
+
try {
|
|
135
|
+
child.unref();
|
|
136
|
+
}
|
|
137
|
+
catch { /* already gone */ }
|
|
138
|
+
}
|
|
139
|
+
/** spawnFn is injected so the timeout path is testable without a network (spec FAQ 7.15). */
|
|
140
|
+
export function makeGitRunner(spawnFn = nodeSpawn) {
|
|
141
|
+
return (args, opts) => new Promise((resolve) => {
|
|
142
|
+
// Spawned directly, NOT through resolveSpawnable: that wrapper exists for npm-installed
|
|
143
|
+
// `.cmd` shims, and its cmd.exe hop breaks any executable path containing a space —
|
|
144
|
+
// `cmd /s /c "C:\Program Files\Git\cmd\git.exe" --version` has its quotes stripped and
|
|
145
|
+
// dies with `'C:\Program' is not recognized`. git ships a real git.exe, which spawn finds
|
|
146
|
+
// through PATHEXT by itself; a `.cmd`-only git fails ENOENT, which reads as gitMissingMessage.
|
|
147
|
+
const child = spawnFn('git', args, {
|
|
148
|
+
env: { ...process.env, ...nonInteractiveEnv() },
|
|
149
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
150
|
+
// Own process group on POSIX, so killTree can reach git's helpers.
|
|
151
|
+
detached: process.platform !== 'win32',
|
|
152
|
+
});
|
|
153
|
+
let stdout = '';
|
|
154
|
+
let stderr = '';
|
|
155
|
+
let settled = false;
|
|
156
|
+
const finish = (r) => {
|
|
157
|
+
if (settled)
|
|
158
|
+
return;
|
|
159
|
+
settled = true;
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
resolve(r);
|
|
162
|
+
};
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
killTree(child);
|
|
165
|
+
// Then let go of the pipes. Killing can miss a grandchild that changed process group, and
|
|
166
|
+
// its inherited write end would keep BOTH 'close' and the CLI's event loop waiting
|
|
167
|
+
// (measured: promise settled at 203ms, process exited at 20094ms).
|
|
168
|
+
releaseChild(child);
|
|
169
|
+
// Settle NOW. 'close' waits for every inherited pipe; waiting would void the bound.
|
|
170
|
+
finish({ code: -1, stdout, stderr, timedOut: true });
|
|
171
|
+
}, opts.timeoutMs);
|
|
172
|
+
child.stdout?.on('data', (b) => { stdout += b.toString(); });
|
|
173
|
+
child.stderr?.on('data', (b) => { stderr += b.toString(); });
|
|
174
|
+
child.on('error', (err) => finish({ code: -1, stdout, stderr: `${stderr}${err.message}`, timedOut: false }));
|
|
175
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr, timedOut: false }));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
export const defaultGitRunner = makeGitRunner();
|
|
179
|
+
export function repoUrl(t) {
|
|
180
|
+
return `https://github.com/${t.owner}/${t.repo}.git`;
|
|
181
|
+
}
|
|
182
|
+
function repoLabel(t) {
|
|
183
|
+
return `https://github.com/${t.owner}/${t.repo}`;
|
|
184
|
+
}
|
|
185
|
+
export function unreadableRepoMessage(t, stderr) {
|
|
186
|
+
const tail = stderr.trim().split('\n').slice(-2).join('\n');
|
|
187
|
+
return [
|
|
188
|
+
`could not read ${repoLabel(t)}: repository not found or not accessible.`,
|
|
189
|
+
'If this is a private repository, configure git credentials and retry. With GitHub CLI:',
|
|
190
|
+
' gh auth login',
|
|
191
|
+
' gh auth setup-git',
|
|
192
|
+
...(tail ? [`git said: ${tail}`] : []),
|
|
193
|
+
].join('\n');
|
|
194
|
+
}
|
|
195
|
+
/** Read `git ls-remote --symref`: the HEAD symref names the default branch, and every other line
|
|
196
|
+
* is `<sha>\t<refname>`. Keyed by FULL ref, so refs/heads/x and refs/tags/x stay distinct.
|
|
197
|
+
* Peeled entries (`^{}`) are dropped: they name a commit, not a ref anyone can clone. */
|
|
198
|
+
export function parseLsRemote(stdout) {
|
|
199
|
+
let head = null;
|
|
200
|
+
const refs = new Map();
|
|
201
|
+
for (const line of stdout.split('\n')) {
|
|
202
|
+
const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(line);
|
|
203
|
+
if (symref) {
|
|
204
|
+
head = symref[1];
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const m = /^([0-9a-f]{40})\s+(refs\/(?:heads|tags)\/.+)$/.exec(line);
|
|
208
|
+
if (!m || m[2].endsWith('^{}'))
|
|
209
|
+
continue;
|
|
210
|
+
refs.set(m[2], m[1]);
|
|
211
|
+
}
|
|
212
|
+
return { head, refs };
|
|
213
|
+
}
|
|
214
|
+
/** Longest-first, because a ref may contain `/` and only the real ref list can say where it ends.
|
|
215
|
+
* A branch beats a tag of the same name, which is what `git clone --branch` does with the short
|
|
216
|
+
* name; qualifiedRef records that choice for the reader, the clone still gets `ref`. */
|
|
217
|
+
export function splitRefAndPath(refAndPath, refs) {
|
|
218
|
+
const segments = refAndPath.split('/');
|
|
219
|
+
for (let take = segments.length; take > 0; take--) {
|
|
220
|
+
const ref = segments.slice(0, take).join('/');
|
|
221
|
+
const qualifiedRef = refs.has(`refs/heads/${ref}`) ? `refs/heads/${ref}`
|
|
222
|
+
: refs.has(`refs/tags/${ref}`) ? `refs/tags/${ref}`
|
|
223
|
+
: null;
|
|
224
|
+
if (qualifiedRef)
|
|
225
|
+
return { ref, qualifiedRef, path: segments.slice(take).join('/') };
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/** One round trip answers two questions: the default branch, and where the ref ends. The commit
|
|
230
|
+
* is NOT taken from here — an annotated tag lists its tag object, and a branch can move before
|
|
231
|
+
* the clone. fetchGitHubTemplate reads it from the checkout instead (spec FAQ 7.10). */
|
|
232
|
+
export async function resolveGitHubRef(t, run) {
|
|
233
|
+
// HEAD is listed EXPLICITLY: adding refspecs otherwise drops the symref line that names the
|
|
234
|
+
// default branch (measured on this repo: 375 refs unfiltered, 188 with the filter, and no
|
|
235
|
+
// `ref: refs/heads/... HEAD` unless HEAD is asked for by name).
|
|
236
|
+
const res = await run(['ls-remote', '--symref', repoUrl(t), 'HEAD', 'refs/heads/*', 'refs/tags/*'], { timeoutMs: LS_REMOTE_TIMEOUT_MS });
|
|
237
|
+
if (res.timedOut)
|
|
238
|
+
throw new Error(`timed out after ${LS_REMOTE_TIMEOUT_MS / 1000}s resolving ${repoLabel(t)}`);
|
|
239
|
+
if (/\bENOENT\b/.test(res.stderr))
|
|
240
|
+
throw new Error(gitMissingMessage());
|
|
241
|
+
if (res.code !== 0)
|
|
242
|
+
throw new Error(unreadableRepoMessage(t, res.stderr));
|
|
243
|
+
const { head, refs } = parseLsRemote(res.stdout);
|
|
244
|
+
if (!t.refAndPath) {
|
|
245
|
+
if (!head)
|
|
246
|
+
throw new Error(unreadableRepoMessage(t, 'the remote named no default branch'));
|
|
247
|
+
return { ref: head, qualifiedRef: `refs/heads/${head}`, path: '' };
|
|
248
|
+
}
|
|
249
|
+
const split = splitRefAndPath(t.refAndPath, refs);
|
|
250
|
+
if (!split)
|
|
251
|
+
throw new Error(`no branch or tag ${t.refAndPath} in ${t.owner}/${t.repo}`);
|
|
252
|
+
return split;
|
|
253
|
+
}
|
|
254
|
+
export function missingManifestMessage(t, r) {
|
|
255
|
+
return [
|
|
256
|
+
`no ${MANIFEST_FILE} at ${t.owner}/${t.repo}@${r.ref}:${r.path || '/'}.`,
|
|
257
|
+
`Point the URL at the directory that contains it, for example https://github.com/${t.owner}/${t.repo}/tree/${r.ref}/templates/<name>`,
|
|
258
|
+
].join(' ');
|
|
259
|
+
}
|
|
260
|
+
export function escapedManifestMessage(t, r) {
|
|
261
|
+
return `${MANIFEST_FILE} at ${t.owner}/${t.repo}@${r.ref}:${r.path || '/'} resolves outside the repository — refusing to read it`;
|
|
262
|
+
}
|
|
263
|
+
// The clone root, symlinks resolved, so containment is compared like with like.
|
|
264
|
+
function containedRealPath(root, candidate) {
|
|
265
|
+
const realRoot = realpathSync(root);
|
|
266
|
+
const real = realpathSync(candidate);
|
|
267
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep))
|
|
268
|
+
return null;
|
|
269
|
+
return real;
|
|
270
|
+
}
|
|
271
|
+
/** Resolve the ref, shallow-clone it, read the commit that was checked out, prove the manifest
|
|
272
|
+
* sits inside the clone, parse it, delete the clone. Nothing on disk outlives this call: not the
|
|
273
|
+
* variable prompt, not the POST, not the watcher. */
|
|
274
|
+
export async function fetchGitHubTemplate(target, run = defaultGitRunner, load = loadTemplateManifest) {
|
|
275
|
+
const resolved = await resolveGitHubRef(target, run);
|
|
276
|
+
const dir = mkdtempSync(join(tmpdir(), 'insta-tpl-gh-'));
|
|
277
|
+
try {
|
|
278
|
+
// The short name, not resolved.qualifiedRef: `--branch` rejects a fully-qualified ref, and
|
|
279
|
+
// git's own short-name tie-break already agrees with splitRefAndPath (spec FAQ 7.14).
|
|
280
|
+
const cloned = await run(['clone', '--depth', '1', '--quiet', '--branch', resolved.ref, repoUrl(target), dir], { timeoutMs: CLONE_TIMEOUT_MS });
|
|
281
|
+
if (cloned.timedOut)
|
|
282
|
+
throw new Error(`timed out after ${CLONE_TIMEOUT_MS / 1000}s cloning https://github.com/${target.owner}/${target.repo}`);
|
|
283
|
+
if (/\bENOENT\b/.test(cloned.stderr))
|
|
284
|
+
throw new Error(gitMissingMessage());
|
|
285
|
+
if (cloned.code !== 0)
|
|
286
|
+
throw new Error(unreadableRepoMessage(target, cloned.stderr));
|
|
287
|
+
// The deployed commit is the one in the checkout: an annotated tag's listing entry is its tag
|
|
288
|
+
// object, and a branch can move between ls-remote and here (spec FAQ 7.10).
|
|
289
|
+
const head = await run(['-C', dir, 'rev-parse', 'HEAD'], { timeoutMs: LS_REMOTE_TIMEOUT_MS });
|
|
290
|
+
// Same three outcomes the other two calls distinguish, so a timeout or a missing git here is
|
|
291
|
+
// not reported as an unreadable repository.
|
|
292
|
+
if (head.timedOut)
|
|
293
|
+
throw new Error(`timed out after ${LS_REMOTE_TIMEOUT_MS / 1000}s reading the clone of ${repoLabel(target)}`);
|
|
294
|
+
if (/\bENOENT\b/.test(head.stderr))
|
|
295
|
+
throw new Error(gitMissingMessage());
|
|
296
|
+
if (head.code !== 0)
|
|
297
|
+
throw new Error(unreadableRepoMessage(target, head.stderr));
|
|
298
|
+
const commit = head.stdout.trim();
|
|
299
|
+
const manifestDir = resolved.path ? join(dir, resolved.path) : dir;
|
|
300
|
+
if (!existsSync(join(manifestDir, MANIFEST_FILE)))
|
|
301
|
+
throw new Error(missingManifestMessage(target, resolved));
|
|
302
|
+
// A committed symlink can point anywhere; only the resolved path proves what is being read.
|
|
303
|
+
const real = containedRealPath(dir, join(manifestDir, MANIFEST_FILE));
|
|
304
|
+
if (!real || !statSync(real).isFile())
|
|
305
|
+
throw new Error(escapedManifestMessage(target, resolved));
|
|
306
|
+
const manifest = load(manifestDir);
|
|
307
|
+
return {
|
|
308
|
+
source: { repo: `${target.owner}/${target.repo}`, ref: resolved.ref, path: resolved.path, commit },
|
|
309
|
+
manifest,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
rmSync(dir, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=github-source.js.map
|
package/dist/index.js
CHANGED
|
@@ -132,7 +132,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
|
|
|
132
132
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
133
133
|
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
|
|
134
134
|
.option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
|
|
135
|
-
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; any plan may attach at the default
|
|
135
|
+
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; any plan may attach at the default 10 (the free cap, on every plan); larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
|
|
136
136
|
.option('--json')
|
|
137
137
|
.action(guard(async (type, name, o) => {
|
|
138
138
|
const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
|
|
@@ -220,7 +220,7 @@ compute.command('restart [service]').description("Restart a compute service by r
|
|
|
220
220
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
|
|
221
221
|
compute.command('status [service]').description("Show a compute service's desired vs. live state")
|
|
222
222
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
|
|
223
|
-
compute.command('limits [service]').description("Show or set a compute service's resource ceiling (paid
|
|
223
|
+
compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
|
|
224
224
|
.option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
|
|
225
225
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
|
|
226
226
|
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
|
|
@@ -231,7 +231,7 @@ const execCmd = compute.command('exec [service]').description("Run a one-shot co
|
|
|
231
231
|
// new option cannot reach the CLI surface while the split still reads it as part of the command.
|
|
232
232
|
for (const [flags, description] of computeCmd.EXEC_OPTIONS)
|
|
233
233
|
execCmd.option(flags, description);
|
|
234
|
-
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default
|
|
234
|
+
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 10Gi, the free cap; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
|
|
235
235
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
236
236
|
.option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
|
|
237
237
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
|
|
@@ -243,7 +243,7 @@ db.command('url').description('Print the postgres connection string (DSN) — ba
|
|
|
243
243
|
db.command('connect').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code")
|
|
244
244
|
.option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
245
245
|
.action(guard((o) => dbCmd.dbConnect(o)));
|
|
246
|
-
db.command('limits').description("Show or set a postgres service's resource ceiling (paid
|
|
246
|
+
db.command('limits').description("Show or set a postgres service's resource ceiling (any plan within the free cap, paid above it; insta-db-backed only). Moves both directions")
|
|
247
247
|
.option('--cpu <n>', "vCPU ceiling, e.g. 2 or 2500m").option('--memory <size>', "memory ceiling, e.g. 4Gi")
|
|
248
248
|
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
249
249
|
.action(guard((o) => dbCmd.dbLimits(o)));
|
|
@@ -281,12 +281,12 @@ storage.command('delete <key>').description('DELETES one object from the bucket
|
|
|
281
281
|
.option('--service <name>', 'storage service (default: the sole one on the branch)')
|
|
282
282
|
.option('--branch <b>', 'branch (default: current)').option('--json')
|
|
283
283
|
.action(guard((key, o) => storageCmd.storageDelete(key, o)));
|
|
284
|
-
// ---- templates (registry
|
|
285
|
-
const tpl = program.command('template').description('Browse and deploy app templates (registry,
|
|
284
|
+
// ---- templates (registry, local insta.template.yaml, or a GitHub URL) ----
|
|
285
|
+
const tpl = program.command('template').description('Browse and deploy app templates (registry, a local dir, or a GitHub URL)');
|
|
286
286
|
tpl.command('list').description('List templates in the platform registry').option('--json').action(guard((o) => template.templateList(o)));
|
|
287
287
|
tpl.command('info <code>').description('Show a template: version, upstream pin, services, and its required/optional variables')
|
|
288
288
|
.option('--json').action(guard((code, o) => template.templateInfo(code, o)));
|
|
289
|
-
tpl.command('deploy <code-or-dir>').description('Deploy a template onto a branch — a registry code,
|
|
289
|
+
tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a branch — a registry code, a local directory containing insta.template.yaml (a path-looking target is always read as a directory), or a github.com URL (https://github.com/<owner>/<repo>[/tree/<ref>[/<dir>]]) whose manifest is fetched with your own git credentials. Missing required variables are prompted for on a terminal; generator-backed (secret:N) and defaulted ones are resolved by the platform')
|
|
290
290
|
.option('--branch <b>', 'target branch (default: current)')
|
|
291
291
|
.option('--set <NAME=value>', 'set a template variable (repeatable)', (v, prev) => [...prev, v], [])
|
|
292
292
|
.option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
|
package/dist/observe/install.js
CHANGED
|
@@ -84,12 +84,15 @@ function claudeEntry() {
|
|
|
84
84
|
// a fresh clone with no ./.insta anywhere above is a silent no-op. The script must stay free of
|
|
85
85
|
// characters either shell rewrites inside double quotes: `$` and backticks (sh), `%` and `!`
|
|
86
86
|
// (cmd.exe), and `"` (both). Codex has each user trust the entry before it runs, so shareable is safe.
|
|
87
|
+
// Exit code: the hook's own status when it ran; 0 when it could not be started or was killed
|
|
88
|
+
// (status null) — every other arm of this install is silent best-effort, and Codex reports any
|
|
89
|
+
// non-zero exit as a failed hook after EVERY tool call, which is worse than a missed audit line.
|
|
87
90
|
const CODEX_HOOK_SCRIPT = [
|
|
88
91
|
"const f=require('fs'),p=require('path'),c=require('child_process');",
|
|
89
92
|
'let d=process.cwd();',
|
|
90
93
|
'for(;;){',
|
|
91
94
|
"const h=p.join(d,'.insta/observe/hook.js');",
|
|
92
|
-
"if(f.existsSync(h)){const r=c.spawnSync(process.execPath,[h],{stdio:'inherit'});process.exitCode=r.status===null?
|
|
95
|
+
"if(f.existsSync(h)){const r=c.spawnSync(process.execPath,[h],{stdio:'inherit'});process.exitCode=r.status===null?0:r.status;break}",
|
|
93
96
|
'const u=p.dirname(d);if(u===d)break;d=u}',
|
|
94
97
|
].join('');
|
|
95
98
|
function codexEntry() {
|