instabox 0.0.0-stage → 0.4.0
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/LICENSE +202 -0
- package/README.md +33 -2
- package/bin/instabox.mjs +405 -0
- package/lib/api.mjs +65 -0
- package/lib/boxes.mjs +15 -0
- package/lib/cli.mjs +98 -0
- package/lib/credentials.mjs +34 -0
- package/lib/login.mjs +35 -0
- package/package.json +31 -4
- package/skills/instabox/SKILL.md +47 -0
- package/skills/instabox-migrate/SKILL.md +132 -0
- package/skills/instabox-snapshots/SKILL.md +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# instabox (npm)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
```
|
|
4
|
+
npx instabox@latest
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Signs you in to instabox in your browser (first run only; Google by default, `--github` for GitHub), lets you pick one of your boxes or start a new one (no boxes yet: a new one, no question; `--box NAME` skips the question), installs the `instabox` CLI already signed in (macOS/Linux; skip with `--no-cli`), installs the agent skills from [`../skills`](../skills) into `~/.claude/skills` and `~/.agents/skills` (skip with `--no-skills`), and drops you into the box over SSH. `npx instabox@latest sh` does the same.
|
|
8
|
+
|
|
9
|
+
Any other command line is the instabox CLI's ([`cmd/instabox`](../cmd/instabox)): `npx instabox@latest ls` runs `instabox ls`, installing the CLI first if it is missing.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
- `bin/instabox.mjs` runs instabox's loopback sign-in (RFC 8252 + PKCE) and saves the tokens where the CLI keeps its own, `~/.config/instabox/credentials.json`, so both are signed in at once. There is one copy on purpose: the server rotates refresh tokens and ends the session when a rotated one comes back.
|
|
14
|
+
- The box list is `instabox ls`: running boxes first, then newest. A suspended or stopped box is resumed or started; its settings (timeout, auto-suspend) are left alone. Without a terminal to ask (an agent running it), it lists the boxes, installs the CLI and skills, and stops; `--box NAME` or `instabox create` takes it from there.
|
|
15
|
+
- The bin is also called `instabox`, so `lib/cli.mjs` finds the real CLI the way the insta CLI does: PATH without `node_modules` dirs and without links back to this script, then the installer's own targets.
|
|
16
|
+
- `prepack` copies `../skills` into the package; every skill there ships on the next publish.
|
|
17
|
+
|
|
18
|
+
## Develop
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm ci
|
|
22
|
+
npm test
|
|
23
|
+
npm run format:check
|
|
24
|
+
npm pack # copies ../skills, then builds instabox-<version>.tgz
|
|
25
|
+
npx --package=./instabox-0.4.0.tgz instabox --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`INSTABOX_API_URL` points it at another instabox server.
|
|
29
|
+
|
|
30
|
+
## Release
|
|
31
|
+
|
|
32
|
+
Bump `version` in `package.json`, merge, then push a tag `npm-v<version>` (for example `git tag npm-v0.4.1 && git push origin npm-v0.4.1`). [`npm-release.yml`](../.github/workflows/npm-release.yml) checks the tag against `package.json`, runs the tests and publishes through npm trusted publishing, so there is no npm token anywhere. A version with a `-` (`0.5.0-rc.1`) goes to the `next` dist-tag, never to `latest`.
|
|
33
|
+
|
|
34
|
+
The trusted publisher is set on npmjs.com, package `instabox` → Settings → Trusted Publisher: GitHub Actions, `InsForge` / `instabox` / `npm-release.yml`. The very first publish, before the package existed, was by hand with `npm publish --access public` from this directory.
|
package/bin/instabox.mjs
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx instabox@latest: box + CLI + skills + SSH in; any other command goes to the instabox CLI.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { tmpdir, homedir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { createInterface } from 'node:readline/promises';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { createClient } from '../lib/api.mjs';
|
|
12
|
+
import { orderBoxes, parseChoice } from '../lib/boxes.mjs';
|
|
13
|
+
import { boxFlag, findCli, installCli, isOnboarding, runCli } from '../lib/cli.mjs';
|
|
14
|
+
import { callbackHandler } from '../lib/login.mjs';
|
|
15
|
+
import { loadCredentials, saveCredentials } from '../lib/credentials.mjs';
|
|
16
|
+
|
|
17
|
+
// Same variables, same order as the instabox CLI, so both talk to one server.
|
|
18
|
+
const API = (
|
|
19
|
+
process.env.INSTABOX_SERVER ||
|
|
20
|
+
process.env.INSTABOX_API_URL ||
|
|
21
|
+
process.env.INSTABOX_API ||
|
|
22
|
+
'https://api.instabox.sh'
|
|
23
|
+
).replace(/\/$/, '');
|
|
24
|
+
const IMAGE = process.env.INSTABOX_IMAGE || 'universal';
|
|
25
|
+
// SSH certificate lifetime for a box with no timeout of its own.
|
|
26
|
+
const CERT_MIN = 120;
|
|
27
|
+
const PROVIDER = process.argv.includes('--github') ? 'github' : 'google';
|
|
28
|
+
const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
29
|
+
const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
30
|
+
const green = (s) => (process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s);
|
|
31
|
+
const enc = encodeURIComponent;
|
|
32
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
|
|
34
|
+
function die(msg) {
|
|
35
|
+
console.error(`\n ${msg}\n`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function usage() {
|
|
40
|
+
console.log(`
|
|
41
|
+
${bold('instabox')}: a cloud box in seconds
|
|
42
|
+
|
|
43
|
+
npx instabox@latest sign in (first time), pick one of your boxes or start a new
|
|
44
|
+
one, SSH into it, and install the instabox CLI and agent skills
|
|
45
|
+
--box NAME go straight to that box instead of asking
|
|
46
|
+
--github sign in with GitHub instead of Google
|
|
47
|
+
--no-cli do not install the instabox CLI
|
|
48
|
+
--no-skills do not install the instabox agent skills
|
|
49
|
+
npx instabox@latest <command> run an instabox CLI command (instabox help lists them)
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- instabox API ------------------------------------------------------------------------------
|
|
54
|
+
const client = createClient(API);
|
|
55
|
+
|
|
56
|
+
function openBrowser(url) {
|
|
57
|
+
const cmd =
|
|
58
|
+
process.platform === 'darwin'
|
|
59
|
+
? ['open', [url]]
|
|
60
|
+
: process.platform === 'win32'
|
|
61
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
62
|
+
: ['xdg-open', [url]];
|
|
63
|
+
try {
|
|
64
|
+
spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true })
|
|
65
|
+
.on('error', () => {})
|
|
66
|
+
.unref();
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The page the browser lands on after sign-in (served from the loopback listener).
|
|
71
|
+
function signInPage(ok, title, text, who) {
|
|
72
|
+
const esc = (s) =>
|
|
73
|
+
String(s ?? '').replace(
|
|
74
|
+
/[&<>"']/g,
|
|
75
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
76
|
+
);
|
|
77
|
+
const accent = ok ? '#6ee7b7' : '#f87171';
|
|
78
|
+
const icon = ok ? '<path d="M7 12.5l3.2 3.2L17 9" />' : '<path d="M8 8l8 8M16 8l-8 8" />';
|
|
79
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
80
|
+
<title>${esc(title)} · instabox</title>
|
|
81
|
+
<style>
|
|
82
|
+
:root { --bg:#0b0b0c; --panel:#141416; --line:#26262a; --fg:#ededed; --dim:#8b8b92; --accent:${accent}; }
|
|
83
|
+
* { box-sizing:border-box; }
|
|
84
|
+
html, body { height:100%; margin:0; }
|
|
85
|
+
body { background:radial-gradient(1200px 600px at 50% -10%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 60%), var(--bg);
|
|
86
|
+
color:var(--fg); font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif; display:grid; place-items:center; padding:24px 16px; }
|
|
87
|
+
.card { width:100%; max-width:420px; background:var(--panel); border:1px solid var(--line); border-radius:16px; padding:36px 28px 28px; text-align:center;
|
|
88
|
+
box-shadow:0 30px 80px rgba(0,0,0,.45); animation:rise .5s cubic-bezier(.2,.9,.3,1.1) both; }
|
|
89
|
+
.brand { font:600 13px ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.08em; color:var(--dim); margin-bottom:28px; }
|
|
90
|
+
.badge { width:64px; height:64px; margin:0 auto 20px; border-radius:50%; display:grid; place-items:center;
|
|
91
|
+
background:color-mix(in srgb, var(--accent) 14%, transparent); border:1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
|
92
|
+
box-shadow:0 0 36px color-mix(in srgb, var(--accent) 35%, transparent); animation:pop .6s .15s cubic-bezier(.2,.9,.3,1.4) both; }
|
|
93
|
+
.badge svg { width:30px; height:30px; stroke:var(--accent); stroke-width:2.4; fill:none; stroke-linecap:round; stroke-linejoin:round;
|
|
94
|
+
stroke-dasharray:30; stroke-dashoffset:30; animation:draw .45s .45s ease-out forwards; }
|
|
95
|
+
h1 { font-size:22px; margin:0 0 6px; }
|
|
96
|
+
.who { color:var(--accent); font-weight:600; margin:0 0 10px; }
|
|
97
|
+
p { color:var(--dim); margin:0; }
|
|
98
|
+
.hint { margin-top:24px; padding-top:18px; border-top:1px solid var(--line); font-size:13px; color:var(--dim); }
|
|
99
|
+
kbd { white-space:nowrap; font:12px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:2px 6px; color:var(--fg); }
|
|
100
|
+
@keyframes rise { from { opacity:0; transform:translateY(12px); } }
|
|
101
|
+
@keyframes pop { from { opacity:0; transform:scale(.4); } }
|
|
102
|
+
@keyframes draw { to { stroke-dashoffset:0; } }
|
|
103
|
+
@media (prefers-reduced-motion: reduce) { .card, .badge, .badge svg { animation:none; stroke-dashoffset:0; } }
|
|
104
|
+
</style></head><body>
|
|
105
|
+
<main class="card">
|
|
106
|
+
<div class="brand">INSTABOX</div>
|
|
107
|
+
<div class="badge"><svg viewBox="0 0 24 24" aria-hidden="true">${icon}</svg></div>
|
|
108
|
+
<h1>${esc(title)}</h1>
|
|
109
|
+
${who ? `<p class="who">Signed in as ${esc(who)}</p>` : ''}
|
|
110
|
+
<p>${esc(text)}</p>
|
|
111
|
+
${ok ? '<div class="hint">You can close this tab. Next time, <kbd>npx instabox@latest</kbd> skips this step.</div>' : ''}
|
|
112
|
+
</main>
|
|
113
|
+
</body></html>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Browser sign-in, the same loopback flow as instabox's own CLI (RFC 8252 + PKCE): listen on
|
|
117
|
+
// 127.0.0.1, open /auth/cli/start, receive the code at /callback, exchange it with the verifier.
|
|
118
|
+
async function browserLogin() {
|
|
119
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
120
|
+
const state = randomBytes(24).toString('base64url');
|
|
121
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
122
|
+
let finish;
|
|
123
|
+
const done = new Promise((r) => {
|
|
124
|
+
finish = r;
|
|
125
|
+
});
|
|
126
|
+
const exchange = async (code) => {
|
|
127
|
+
const tok = await client.anon('POST', '/v1/auth/cli/token', {
|
|
128
|
+
code,
|
|
129
|
+
codeVerifier: verifier,
|
|
130
|
+
redirectUri: redirect,
|
|
131
|
+
});
|
|
132
|
+
client.use(tok);
|
|
133
|
+
return { tok, me: await client.call('GET', '/v1/me').catch(() => null) };
|
|
134
|
+
};
|
|
135
|
+
const page = {
|
|
136
|
+
stale: () => signInPage(false, 'This sign-in link is stale', 'Run the command in your terminal again.'),
|
|
137
|
+
again: () => signInPage(true, 'Already signing you in', 'Head back to your terminal.'),
|
|
138
|
+
ok: (r) => signInPage(true, "You're in", 'Head back to your terminal.', r.me && (r.me.name || r.me.login)),
|
|
139
|
+
failed: (err) => signInPage(false, 'Sign-in failed', `${err}. Return to your terminal and run the command again.`),
|
|
140
|
+
};
|
|
141
|
+
const server = http.createServer(callbackHandler({ state, exchange, finish, page }));
|
|
142
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
143
|
+
const redirect = `http://127.0.0.1:${server.address().port}/callback`;
|
|
144
|
+
const q = { redirect_uri: redirect, state, code_challenge: challenge, code_challenge_method: 'S256' };
|
|
145
|
+
if (PROVIDER) q.provider = PROVIDER;
|
|
146
|
+
const start = `${API}/auth/cli/start?` + new URLSearchParams(q);
|
|
147
|
+
console.log(` ${bold('Sign in to instabox')} in your browser ${dim('(opening it now)')}`);
|
|
148
|
+
console.log(` ${dim('If it did not open:')} ${start}\n`);
|
|
149
|
+
openBrowser(start);
|
|
150
|
+
const timer = setTimeout(() => finish({ err: 'timed out waiting for the browser sign-in' }), 5 * 60_000);
|
|
151
|
+
const result = await done;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
server.close();
|
|
154
|
+
if (result.err) die(`sign-in failed: ${result.err}`);
|
|
155
|
+
return result.tok;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The CLI's saved token, a refreshed one, or a browser sign-in; saved where the CLI reads it.
|
|
159
|
+
async function signedIn() {
|
|
160
|
+
const c = loadCredentials(API);
|
|
161
|
+
if (c?.accessToken && new Date(c.accessExpiresAt) - Date.now() > 60_000) return c;
|
|
162
|
+
if (c?.refreshToken && new Date(c.refreshExpiresAt) > Date.now()) {
|
|
163
|
+
try {
|
|
164
|
+
const t = await client.anon('POST', '/v1/auth/refresh', { refreshToken: c.refreshToken });
|
|
165
|
+
saveCredentials(API, t);
|
|
166
|
+
return t;
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
const t = await browserLogin();
|
|
170
|
+
saveCredentials(API, t);
|
|
171
|
+
return t;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function waitOp(op) {
|
|
175
|
+
let delay = 250;
|
|
176
|
+
const deadline = Date.now() + 120_000;
|
|
177
|
+
while (op.state !== 'succeeded' && op.state !== 'failed') {
|
|
178
|
+
if (Date.now() > deadline) throw new Error('timed out waiting for the box');
|
|
179
|
+
await sleep(delay);
|
|
180
|
+
delay = Math.min(delay * 1.5, 1000);
|
|
181
|
+
op = await client.call('GET', '/v1/operations/' + enc(op.id));
|
|
182
|
+
}
|
|
183
|
+
if (op.state === 'failed') throw new Error(op.error?.message || 'box creation failed');
|
|
184
|
+
return op;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const LISTED = Symbol('listed');
|
|
188
|
+
|
|
189
|
+
// The box to enter, null for a new one, or LISTED when there is no terminal to ask which one.
|
|
190
|
+
async function chooseBox() {
|
|
191
|
+
const { machines = [] } = await client
|
|
192
|
+
.call('GET', '/v1/machines?limit=100')
|
|
193
|
+
.catch((e) => die(`could not list your boxes: ${e.message}`));
|
|
194
|
+
const boxes = orderBoxes(machines);
|
|
195
|
+
const wanted = boxFlag(process.argv.slice(2));
|
|
196
|
+
if (wanted) return boxes.find((b) => b.name === wanted || b.id === wanted) || die(`you have no box named ${wanted}`);
|
|
197
|
+
if (!boxes.length) return null;
|
|
198
|
+
const ask = process.stdin.isTTY && process.stdout.isTTY;
|
|
199
|
+
const w = Math.max(...boxes.map((b) => b.name.length));
|
|
200
|
+
console.log(`\n ${bold('Your boxes')}`);
|
|
201
|
+
boxes.forEach((b, i) =>
|
|
202
|
+
console.log(
|
|
203
|
+
` ${ask ? `${i + 1}) ` : ''}${b.name.padEnd(w)} ${b.state.padEnd(9)} ${dim(`${b.template?.id || ''} created ${new Date(b.createdAt).toLocaleString()}`)}`,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
if (!ask) return LISTED;
|
|
207
|
+
console.log(` ${boxes.length + 1}) ${green('+ new box')}`);
|
|
208
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
209
|
+
try {
|
|
210
|
+
for (;;) {
|
|
211
|
+
const i = parseChoice(await rl.question(' Enter a box [1]: '), boxes.length);
|
|
212
|
+
if (i !== null) return i === boxes.length ? null : boxes[i];
|
|
213
|
+
}
|
|
214
|
+
} finally {
|
|
215
|
+
rl.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Wakes a suspended or stopped box and leaves its settings (timeout, auto-suspend) alone.
|
|
220
|
+
async function enterBox(id) {
|
|
221
|
+
const path = '/v1/machines/' + enc(id);
|
|
222
|
+
let m = await client.call('GET', path);
|
|
223
|
+
if (m.operationId) {
|
|
224
|
+
await waitOp(await client.call('GET', '/v1/operations/' + enc(m.operationId)));
|
|
225
|
+
m = await client.call('GET', path);
|
|
226
|
+
}
|
|
227
|
+
if (m.state === 'suspended' || m.state === 'stopped') {
|
|
228
|
+
await waitOp(await client.call('POST', `${path}/${m.state === 'suspended' ? 'resume' : 'start'}`));
|
|
229
|
+
return { id: m.id, resumed: true };
|
|
230
|
+
}
|
|
231
|
+
if (m.state !== 'running') throw new Error(`box ${m.name} is ${m.state}`);
|
|
232
|
+
return { id: m.id, reused: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function newBox() {
|
|
236
|
+
const { images } = await client.call('GET', '/v1/images');
|
|
237
|
+
const named = images.filter((i) => i.name === IMAGE || i.id === IMAGE);
|
|
238
|
+
const img = named.find((i) => i.official) || named[0];
|
|
239
|
+
const body = {
|
|
240
|
+
name: `demo-${randomBytes(3).toString('hex')}`,
|
|
241
|
+
desiredState: 'running',
|
|
242
|
+
};
|
|
243
|
+
if (img) body.imageId = img.id;
|
|
244
|
+
else body.templateId = IMAGE;
|
|
245
|
+
const op = await waitOp(await client.call('POST', '/v1/machines', body));
|
|
246
|
+
return { id: op.machineId, reused: false };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Runs while the box starts; never fatal. Returns a line to print, or null.
|
|
250
|
+
async function ensureCli() {
|
|
251
|
+
if (process.argv.includes('--no-cli')) return null;
|
|
252
|
+
if (findCli()) return `instabox CLI already installed ${dim('· try')} instabox ls`;
|
|
253
|
+
if (process.platform === 'win32') return null;
|
|
254
|
+
const r = await installCli(API);
|
|
255
|
+
if (r.error) return `instabox CLI not installed ${dim(`(${r.error})`)}`;
|
|
256
|
+
return (
|
|
257
|
+
`instabox CLI installed${r.path ? ` at ${r.path}` : ''} ${dim('· try')} instabox ls` +
|
|
258
|
+
(r.pathHint ? `\n ${dim('add it to your PATH:')} ${r.pathHint}` : '')
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Agent skills shipped in this package (skills/<name>/SKILL.md) into the folders coding agents
|
|
263
|
+
// read. Overwrites only copies we installed earlier (marked `installed-by: instabox-demo`).
|
|
264
|
+
function installSkills() {
|
|
265
|
+
if (process.argv.includes('--no-skills')) return null;
|
|
266
|
+
const src = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
267
|
+
let names;
|
|
268
|
+
try {
|
|
269
|
+
names = readdirSync(src).filter((n) => existsSync(join(src, n, 'SKILL.md')));
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const roots = [join(homedir(), '.claude', 'skills'), join(homedir(), '.agents', 'skills')];
|
|
274
|
+
const done = new Set();
|
|
275
|
+
for (const root of roots) {
|
|
276
|
+
for (const n of names) {
|
|
277
|
+
const dest = join(root, n, 'SKILL.md');
|
|
278
|
+
try {
|
|
279
|
+
if (existsSync(dest) && !readFileSync(dest, 'utf8').includes('installed-by: instabox-demo')) continue;
|
|
280
|
+
mkdirSync(join(root, n), { recursive: true });
|
|
281
|
+
writeFileSync(dest, readFileSync(join(src, n, 'SKILL.md')));
|
|
282
|
+
done.add(n);
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return done.size
|
|
287
|
+
? `agent skills installed: ${[...done].join(', ')} ${dim('(~/.claude/skills, ~/.agents/skills)')}`
|
|
288
|
+
: null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Waits for the CLI install started earlier, installs the skills, and prints a line for each.
|
|
292
|
+
async function reportInstalls(cli) {
|
|
293
|
+
const cliLine = await cli;
|
|
294
|
+
if (cliLine) console.log(` ${green('✓')} ${cliLine}`);
|
|
295
|
+
const skillsLine = installSkills();
|
|
296
|
+
if (skillsLine) console.log(` ${green('✓')} ${skillsLine}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function sh() {
|
|
300
|
+
console.log(`\n ${bold('instabox')} ${dim('· a cloud box in seconds')}\n`);
|
|
301
|
+
|
|
302
|
+
client.use(await signedIn());
|
|
303
|
+
const me = await client.call('GET', '/v1/me').catch((e) => die(`instabox did not accept the sign-in: ${e.message}`));
|
|
304
|
+
console.log(
|
|
305
|
+
` ${dim('signed in as')} ${bold(me.name || me.login)}${me.login && me.name ? dim(` (${me.login})`) : ''} ${dim('· not you?')} npx instabox@latest logout`,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const cli = ensureCli();
|
|
309
|
+
const picked = await chooseBox();
|
|
310
|
+
if (picked === LISTED) {
|
|
311
|
+
await reportInstalls(cli);
|
|
312
|
+
console.log(
|
|
313
|
+
`\n Which box? ${bold('npx instabox@latest --box NAME')} enters one, ${bold('instabox create --name NAME')} starts a new one.\n`,
|
|
314
|
+
);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Only entering a box needs OpenSSH; the list-and-stop path above runs without it.
|
|
318
|
+
for (const bin of ['ssh', 'ssh-keygen']) {
|
|
319
|
+
if (spawnSync(bin, ['-V'], { stdio: 'ignore' }).error) die(`${bin} was not found. Install OpenSSH and try again.`);
|
|
320
|
+
}
|
|
321
|
+
const t0 = Date.now();
|
|
322
|
+
process.stdout.write(`\n ${dim(picked ? `opening ${picked.name}…` : 'starting a new box…')}`);
|
|
323
|
+
const box = await (picked ? enterBox(picked.id) : newBox()).catch((e) =>
|
|
324
|
+
die(`could not start your box: ${e.message}`),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// A throwaway key for this session; only its public half leaves this machine.
|
|
328
|
+
const dir = mkdtempSync(join(tmpdir(), 'instabox-'));
|
|
329
|
+
process.on('exit', () => {
|
|
330
|
+
try {
|
|
331
|
+
rmSync(dir, { recursive: true, force: true });
|
|
332
|
+
} catch {}
|
|
333
|
+
});
|
|
334
|
+
const key = join(dir, 'id_ed25519');
|
|
335
|
+
const kg = spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'instabox', '-f', key], {
|
|
336
|
+
stdio: 'ignore',
|
|
337
|
+
});
|
|
338
|
+
if (kg.status !== 0 || !existsSync(key + '.pub')) die('could not create an SSH key (ssh-keygen failed)');
|
|
339
|
+
const m = await client.call('GET', '/v1/machines/' + enc(box.id));
|
|
340
|
+
const ttlSeconds = Math.max(
|
|
341
|
+
60,
|
|
342
|
+
Math.floor((new Date(m.timeoutAt || Date.now() + CERT_MIN * 60_000) - Date.now()) / 1000),
|
|
343
|
+
);
|
|
344
|
+
const c = await client.call('POST', `/v1/machines/${enc(box.id)}/connections`, {
|
|
345
|
+
publicKey: readFileSync(key + '.pub', 'utf8').trim(),
|
|
346
|
+
ttlSeconds,
|
|
347
|
+
});
|
|
348
|
+
writeFileSync(key + '-cert.pub', c.certificate.trim() + '\n');
|
|
349
|
+
writeFileSync(join(dir, 'known_hosts'), `@cert-authority [${c.host}]:${c.port} ${c.hostTrust.publicKey}\n`);
|
|
350
|
+
|
|
351
|
+
const pauses = m.timeoutAt ? dim(` · pauses itself in ${Math.max(1, Math.round(ttlSeconds / 60))} min`) : '';
|
|
352
|
+
const took = box.resumed
|
|
353
|
+
? `resumed in ${((Date.now() - t0) / 1000).toFixed(1)}s`
|
|
354
|
+
: box.reused
|
|
355
|
+
? 'back in'
|
|
356
|
+
: `ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
357
|
+
process.stdout.write(`${process.stdout.isTTY ? '\r' : '\n'} ${green('✓')} box ${bold(m.name)} ${took}${pauses}\n`);
|
|
358
|
+
await reportInstalls(cli);
|
|
359
|
+
console.log(dim(` type ${'exit'} to leave · stuck? press Enter, then ~.`));
|
|
360
|
+
// The box's prompt is ~37 columns; narrower terminals make bash scroll the input line sideways.
|
|
361
|
+
if (process.stdout.isTTY && process.stdout.columns > 0 && process.stdout.columns < 60)
|
|
362
|
+
console.log(dim(` your terminal is ${process.stdout.columns} columns wide; widen it for a readable prompt`));
|
|
363
|
+
console.log();
|
|
364
|
+
|
|
365
|
+
const args = [
|
|
366
|
+
'-i',
|
|
367
|
+
key,
|
|
368
|
+
'-o',
|
|
369
|
+
`CertificateFile=${key}-cert.pub`,
|
|
370
|
+
'-o',
|
|
371
|
+
`UserKnownHostsFile=${join(dir, 'known_hosts')}`,
|
|
372
|
+
'-o',
|
|
373
|
+
'IdentitiesOnly=yes',
|
|
374
|
+
'-o',
|
|
375
|
+
'StrictHostKeyChecking=yes',
|
|
376
|
+
'-o',
|
|
377
|
+
'LogLevel=ERROR',
|
|
378
|
+
'-p',
|
|
379
|
+
String(c.port),
|
|
380
|
+
'-t',
|
|
381
|
+
`${c.username}@${c.host}`,
|
|
382
|
+
];
|
|
383
|
+
const child = spawn('ssh', args, { stdio: 'inherit' });
|
|
384
|
+
child.on('exit', (code) => {
|
|
385
|
+
console.log(`\n ${dim(`left ${m.name}. Run`)} npx instabox@latest ${dim('again to get back in.')}\n`);
|
|
386
|
+
process.exit(code ?? 0);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Any other command line is the CLI's, so `npm i -g instabox` still gives a working `instabox`.
|
|
391
|
+
const args = process.argv.slice(2);
|
|
392
|
+
if (args.length === 1 && (args[0] === '-h' || args[0] === '--help')) usage();
|
|
393
|
+
else if (isOnboarding(args)) await sh();
|
|
394
|
+
else {
|
|
395
|
+
let path = findCli();
|
|
396
|
+
if (!path) {
|
|
397
|
+
if (process.platform === 'win32') die('the instabox CLI runs on macOS and Linux only');
|
|
398
|
+
console.error(dim(' installing the instabox CLI…'));
|
|
399
|
+
const r = await installCli(API);
|
|
400
|
+
if (r.error) die(`could not install the instabox CLI: ${r.error}`);
|
|
401
|
+
if (!r.path) die('the installer finished but the instabox CLI was not found');
|
|
402
|
+
path = r.path;
|
|
403
|
+
}
|
|
404
|
+
runCli(path, args);
|
|
405
|
+
}
|
package/lib/api.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// instabox API calls; a 401 renews the session once and replays the call, as the insta CLI does.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { loadCredentials, saveCredentials } from './credentials.mjs';
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createClient(
|
|
13
|
+
server,
|
|
14
|
+
{ fetch = globalThis.fetch, load = () => loadCredentials(server), save = (t) => saveCredentials(server, t) } = {},
|
|
15
|
+
) {
|
|
16
|
+
let session = null;
|
|
17
|
+
|
|
18
|
+
const send = (token, method, path, body, key) => {
|
|
19
|
+
const headers = { Accept: 'application/json' };
|
|
20
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
21
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
22
|
+
if (key) headers['Idempotency-Key'] = key;
|
|
23
|
+
return fetch(server + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const parse = async (res) => {
|
|
27
|
+
const data = res.status === 204 ? null : await res.json().catch(() => null);
|
|
28
|
+
if (!res.ok) throw new ApiError(res.status, data?.error?.message || `instabox HTTP ${res.status}`);
|
|
29
|
+
return data;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const anon = async (method, path, body) =>
|
|
33
|
+
parse(await send(null, method, path, body, method === 'GET' ? null : randomUUID()));
|
|
34
|
+
|
|
35
|
+
// The CLI's newer token if it already refreshed (a reused refresh token ends the session), else one rotation.
|
|
36
|
+
async function renew() {
|
|
37
|
+
const c = load();
|
|
38
|
+
if (c?.accessToken && c.accessToken !== session.accessToken && new Date(c.accessExpiresAt) - Date.now() > 60_000) {
|
|
39
|
+
session = c;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
session = await anon('POST', '/v1/auth/refresh', { refreshToken: c?.refreshToken || session.refreshToken });
|
|
44
|
+
save(session);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function call(method, path, body) {
|
|
52
|
+
const key = method === 'GET' ? null : randomUUID();
|
|
53
|
+
let res = await send(session.accessToken, method, path, body, key);
|
|
54
|
+
if (res.status === 401 && (await renew())) res = await send(session.accessToken, method, path, body, key);
|
|
55
|
+
return parse(res);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
anon,
|
|
60
|
+
call,
|
|
61
|
+
use(tokens) {
|
|
62
|
+
session = tokens;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/lib/boxes.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Which of your boxes to offer, in order: live ones only, running first, then newest.
|
|
2
|
+
export function orderBoxes(machines) {
|
|
3
|
+
return machines
|
|
4
|
+
.filter((m) => !['deleting', 'deleted', 'error'].includes(m.state))
|
|
5
|
+
.sort((a, b) => (b.state === 'running') - (a.state === 'running') || new Date(b.createdAt) - new Date(a.createdAt));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// The 0-based pick from a typed answer: Enter is the first box, count itself is "new box", else null.
|
|
9
|
+
export function parseChoice(answer, count) {
|
|
10
|
+
const s = answer.trim();
|
|
11
|
+
if (s === '') return 0;
|
|
12
|
+
if (!/^\d+$/.test(s)) return null;
|
|
13
|
+
const n = Number(s);
|
|
14
|
+
return n >= 1 && n <= count + 1 ? n - 1 : null;
|
|
15
|
+
}
|
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The Go instabox CLI: find it, install it, hand it the command line.
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { delimiter, join, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const OUR_FLAGS = new Set(['--github', '--no-cli', '--no-skills']);
|
|
8
|
+
|
|
9
|
+
// Bare `instabox` (or the old `sh`) with only our flags; anything else is a CLI command.
|
|
10
|
+
export function isOnboarding(args) {
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const a = args[i];
|
|
13
|
+
if (a === '--box' && i + 1 < args.length) i++;
|
|
14
|
+
else if (a !== 'sh' && !OUR_FLAGS.has(a) && !a.startsWith('--box=')) return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The box named by --box NAME or --box=NAME, else null.
|
|
20
|
+
export function boxFlag(args) {
|
|
21
|
+
const i = args.indexOf('--box');
|
|
22
|
+
if (i !== -1) return args[i + 1] ?? null;
|
|
23
|
+
const eq = args.find((a) => a.startsWith('--box='));
|
|
24
|
+
return eq ? eq.slice('--box='.length) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const real = (p) => {
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(p);
|
|
30
|
+
} catch {
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function runnable(p) {
|
|
36
|
+
try {
|
|
37
|
+
const s = statSync(p);
|
|
38
|
+
return s.isFile() && (s.mode & 0o111) !== 0;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Skips node_modules dirs and links back to us, like insta's findDurableOnPath.
|
|
45
|
+
export function findCli({ env = process.env, self = process.argv[1] } = {}) {
|
|
46
|
+
const me = real(self);
|
|
47
|
+
const home = env.HOME || homedir();
|
|
48
|
+
const dirs = [
|
|
49
|
+
...(env.PATH || '').split(delimiter),
|
|
50
|
+
env.INSTABOX_INSTALL_DIR,
|
|
51
|
+
'/usr/local/bin',
|
|
52
|
+
join(home, '.local', 'bin'),
|
|
53
|
+
];
|
|
54
|
+
for (const dir of dirs) {
|
|
55
|
+
if (!dir || dir.toLowerCase().includes('node_modules')) continue;
|
|
56
|
+
const p = join(dir, 'instabox');
|
|
57
|
+
if (!runnable(p)) continue;
|
|
58
|
+
const r = real(p);
|
|
59
|
+
if (r === me || r.includes(`${sep}node_modules${sep}`)) continue;
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The URL is "$1", never script text; a failed download stops before sh runs anything.
|
|
66
|
+
const INSTALL = 's=$(curl -fsSL "$1/install.sh") && printf \'%s\\n\' "$s" | sh';
|
|
67
|
+
|
|
68
|
+
// path is null, never a bare `instabox` that could resolve back to this script.
|
|
69
|
+
export function installCli(api) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let out = '';
|
|
72
|
+
const p = spawn('sh', ['-c', INSTALL, 'sh', api], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
73
|
+
p.stdout.on('data', (d) => {
|
|
74
|
+
out += d;
|
|
75
|
+
});
|
|
76
|
+
p.stderr.on('data', (d) => {
|
|
77
|
+
out += d;
|
|
78
|
+
});
|
|
79
|
+
p.on('error', (e) => resolve({ error: e.message }));
|
|
80
|
+
p.on('close', (code) => {
|
|
81
|
+
if (code !== 0) return resolve({ error: out.trim().split('\n').pop() || 'installer failed' });
|
|
82
|
+
resolve({
|
|
83
|
+
path: (out.match(/^Installed (.+) \([^()]*, sha256 [0-9a-f]+\)$/m) || [])[1] || findCli(),
|
|
84
|
+
pathHint: (out.match(/export PATH="[^"]+"/) || [])[0] || null,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Hand the command line to the Go CLI and exit with its status.
|
|
91
|
+
export function runCli(path, args) {
|
|
92
|
+
const r = spawnSync(path, args, { stdio: 'inherit' });
|
|
93
|
+
if (r.error) {
|
|
94
|
+
console.error(`instabox: could not run ${path}: ${r.error.message}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
process.exit(r.status ?? 1);
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The instabox CLI's credentials.json (cmd/instabox/creds.go), so one sign-in serves both.
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const trim = (s) => String(s ?? '').replace(/\/+$/, '');
|
|
8
|
+
|
|
9
|
+
export function configDir(env = process.env) {
|
|
10
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'instabox');
|
|
11
|
+
return join(env.HOME || homedir(), '.config', 'instabox');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Tokens stored for `server`, else null.
|
|
15
|
+
export function loadCredentials(server, env = process.env) {
|
|
16
|
+
try {
|
|
17
|
+
const c = JSON.parse(readFileSync(join(configDir(env), 'credentials.json'), 'utf8'));
|
|
18
|
+
return trim(c.server) === trim(server) ? c : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Written to a temp file and renamed over, so the CLI never reads half a file.
|
|
25
|
+
export function saveCredentials(server, tokens, env = process.env) {
|
|
26
|
+
const dir = configDir(env);
|
|
27
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
chmodSync(dir, 0o700);
|
|
29
|
+
const { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt } = tokens;
|
|
30
|
+
const body = JSON.stringify({ server, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }, null, 2) + '\n';
|
|
31
|
+
const tmp = join(dir, `.credentials.${randomBytes(6).toString('hex')}.tmp`);
|
|
32
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
33
|
+
renameSync(tmp, join(dir, 'credentials.json'));
|
|
34
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The loopback sign-in callback (RFC 8252); one code exchange per sign-in, however often it is hit.
|
|
2
|
+
export function callbackHandler({ state, exchange, finish, page }) {
|
|
3
|
+
let taken = false;
|
|
4
|
+
return async (req, res) => {
|
|
5
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
6
|
+
const html = { 'Content-Type': 'text/html; charset=utf-8' };
|
|
7
|
+
if (u.pathname !== '/callback') {
|
|
8
|
+
res.writeHead(404);
|
|
9
|
+
return res.end();
|
|
10
|
+
}
|
|
11
|
+
if (u.searchParams.get('state') !== state) {
|
|
12
|
+
res.writeHead(400, html);
|
|
13
|
+
return res.end(page.stale());
|
|
14
|
+
}
|
|
15
|
+
// A reload or a double navigation: the server revokes the whole session when a code comes back.
|
|
16
|
+
if (taken) {
|
|
17
|
+
res.writeHead(200, html);
|
|
18
|
+
return res.end(page.again());
|
|
19
|
+
}
|
|
20
|
+
taken = true;
|
|
21
|
+
let result;
|
|
22
|
+
if (u.searchParams.get('error')) {
|
|
23
|
+
result = { err: u.searchParams.get('error_description') || u.searchParams.get('error') };
|
|
24
|
+
} else {
|
|
25
|
+
try {
|
|
26
|
+
result = await exchange(u.searchParams.get('code'));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
result = { err: e.message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
res.writeHead(200, html);
|
|
32
|
+
res.end(result.tok ? page.ok(result) : page.failed(result.err));
|
|
33
|
+
finish(result);
|
|
34
|
+
};
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "instabox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "A cloud box for your coding agent, the instabox CLI and its agent skills in one command: npx instabox@latest",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"instabox": "bin/instabox.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"skills/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepack": "rm -rf skills && cp -R ../skills skills",
|
|
16
|
+
"test": "node --test test/*.test.mjs",
|
|
17
|
+
"format": "prettier --write .",
|
|
18
|
+
"format:check": "prettier --check ."
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.17"
|
|
22
|
+
},
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"homepage": "https://instabox.sh",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/InsForge/instabox.git",
|
|
28
|
+
"directory": "npm"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"prettier": "3.9.9"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: instabox
|
|
3
|
+
description: Use when the user wants a cloud Linux box (instabox sandbox) from the terminal with the `instabox` CLI — create one, SSH in, run a command in it, read its files, set its timeout, or delete it. For saving or restoring a box's disks, see instabox-snapshots.
|
|
4
|
+
metadata:
|
|
5
|
+
installed-by: instabox-demo
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# instabox CLI
|
|
9
|
+
|
|
10
|
+
An instabox sandbox is a Linux VM (Debian, user `dev`, sudo). It has a root disk and a persistent `/data` disk; HOME is `/data/home`. Boxes are private to the signed-in account.
|
|
11
|
+
|
|
12
|
+
## Sign in
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
instabox whoami # who is signed in (fails if nobody)
|
|
16
|
+
instabox login # browser sign-in (GitHub); --provider google for Google
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For CI or an agent without a browser, use an API key instead: `export INSTABOX_API_KEY=ibk_...` (the user creates it on the dashboard's API Keys page). Never print, commit or log a key.
|
|
20
|
+
|
|
21
|
+
## Everyday commands
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
instabox templates # base systems to start from
|
|
25
|
+
instabox create --name work --timeout 2h # new box; waits until it is running
|
|
26
|
+
instabox ls # your boxes and their states
|
|
27
|
+
instabox ssh work # interactive shell (leave with: exit)
|
|
28
|
+
instabox exec work -- uname -a # one command, no SSH client needed
|
|
29
|
+
instabox exec work --cwd /data/home -- ls -la # exec runs `bash -lc` as dev
|
|
30
|
+
instabox files work /data/home # list a directory (or print a text file)
|
|
31
|
+
instabox timeout work 2h --on-timeout pause # pause (keeps memory) or kill at the deadline
|
|
32
|
+
instabox stop work && instabox start work # stop ends processes, keeps disks
|
|
33
|
+
instabox suspend work && instabox resume work # pause/resume keeps running processes
|
|
34
|
+
instabox rm work # delete the box
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- Names or IDs (`m_...`) both work wherever `<id-or-name>` is accepted.
|
|
38
|
+
- `create` options: `--template T` or `--snapshot S`, `--vcpus N`, `--mem-mib N`, `--disk-gib N`, `--meta K=V`, `--no-wait`.
|
|
39
|
+
- `exec` returns the command's exit code; add `--timeout S` for long jobs and `--stdin` to pipe input.
|
|
40
|
+
- Prefer `exec` over `ssh` when you (an agent) need output; use `ssh` only for interactive work the user will drive.
|
|
41
|
+
- A public URL for a dev server in the box: `instabox preview expose work --port 3000`.
|
|
42
|
+
|
|
43
|
+
## Habits
|
|
44
|
+
|
|
45
|
+
- Give boxes a `--timeout` so forgotten ones pause or delete themselves.
|
|
46
|
+
- Check `instabox ls` before creating, and reuse a running box with the same purpose.
|
|
47
|
+
- `instabox usage` shows storage use and limits. A quota error means delete or stop something; do not retry the same create in a loop.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: instabox-migrate
|
|
3
|
+
description: Use when the user wants to move their local project or setup onto instabox — "move my setup to instabox", "migrate this repo to a box", "continue this work in the cloud". Copies the current git repo (uncommitted and untracked work included, .gitignored files left out) and its Claude Code / Codex conversation history into a new box, then installs dependencies there until it builds.
|
|
4
|
+
metadata:
|
|
5
|
+
installed-by: instabox-demo
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Move a local project to instabox
|
|
9
|
+
|
|
10
|
+
Moves the git repo of the current directory into one new box (see the `instabox` skill for the CLI). What goes over:
|
|
11
|
+
|
|
12
|
+
- The whole `.git` directory (branches, stashes, the index, so staged changes stay staged) and every working-tree file that is tracked or untracked but **not ignored by git**. Files git ignores (usually `node_modules`, build output, `.env`) stay behind: dependencies are reinstalled on the box. Untracked `.env` and `.env.*` files are also left out even when `.gitignore` does not list them. A `.env` that is committed or staged (`git add`ed) is in `.git` and goes with it.
|
|
13
|
+
- Claude Code and Codex conversations about this project, copied unchanged.
|
|
14
|
+
|
|
15
|
+
Not moved: credentials (git, gh, cloud, API keys), other local tools and config, other repos.
|
|
16
|
+
|
|
17
|
+
Before starting, tell the user plainly:
|
|
18
|
+
|
|
19
|
+
- **git push/pull from the box will not work until they set up credentials there** (for example `instabox ssh <box>` then `gh auth login`).
|
|
20
|
+
- **The copied conversation history may contain secrets they pasted earlier** (tokens, keys, passwords). Offer to skip the history.
|
|
21
|
+
|
|
22
|
+
## 1. Check and create the box
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
instabox whoami # signed in? otherwise: instabox login
|
|
26
|
+
git rev-parse --show-toplevel # must be inside a git repo
|
|
27
|
+
du -sh "$(git rev-parse --show-toplevel)/.git" # large repos: add --disk-gib to create
|
|
28
|
+
instabox ls # pick a box name that is not taken
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If `.git` is a file (a git worktree or submodule checkout), move the main checkout instead. Submodules: their history comes along inside `.git/modules`; run `git submodule update --init` on the box. Uncommitted changes inside a submodule are not copied.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
instabox create --name <box> --vcpus 4 --mem-mib 8192
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Box names are lowercase letters, digits and `-`. The default template (`universal`) has git, node/npm, python3/uv, go, cargo and docker. Do not add a kill timeout: this box is meant to stay (offer `instabox timeout <box> 8h --on-timeout pause` if the user wants it to pause itself).
|
|
38
|
+
|
|
39
|
+
## 2. Copy the repo and history
|
|
40
|
+
|
|
41
|
+
Run this with **bash** from inside the repo; set `BOX` first. The repo lands in `/data/workspace/<repo-name>` (`/data` is the box's persistent disk; HOME is `/data/home`).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
BOX=<box>
|
|
45
|
+
export COPYFILE_DISABLE=1 # macOS tar: no ._ AppleDouble files
|
|
46
|
+
SRC=$(git rev-parse --show-toplevel)
|
|
47
|
+
NAME=$(basename "$SRC" | LC_ALL=C tr -c 'A-Za-z0-9._\n-' '-') # safe inside the quoted remote commands
|
|
48
|
+
DEST=/data/workspace/$NAME
|
|
49
|
+
OLD_ENC=$(printf %s "$SRC" | sed 's/[^A-Za-z0-9]/-/g') # Claude Code's project dir name
|
|
50
|
+
NEW_ENC=$(printf %s "$DEST" | sed 's/[^A-Za-z0-9]/-/g')
|
|
51
|
+
X='tar -xzf - --warning=no-unknown-keyword' # the box runs GNU tar
|
|
52
|
+
cd "$SRC"
|
|
53
|
+
|
|
54
|
+
# Repo: all of .git, plus tracked files and untracked files that are not ignored, if they still exist.
|
|
55
|
+
# Untracked .env / .env.* files are skipped even if .gitignore misses them.
|
|
56
|
+
{ find .git -print0
|
|
57
|
+
{ git ls-files -z --cached
|
|
58
|
+
git ls-files -z --others --exclude-standard -- . ':(exclude,glob)**/.env' ':(exclude,glob)**/.env.*'
|
|
59
|
+
} | while IFS= read -r -d '' f; do { [ -e "$f" ] || [ -L "$f" ]; } && printf '%s\0' "$f"; done
|
|
60
|
+
} | tar --null --no-recursion -T - -czf - |
|
|
61
|
+
instabox ssh "$BOX" -- "mkdir -p '$DEST' && $X -C '$DEST'"
|
|
62
|
+
|
|
63
|
+
# Claude Code history: ~/.claude/projects/<encoded path>/ (the name starts with '-': keep the ./)
|
|
64
|
+
[ -d ~/.claude/projects/"$OLD_ENC" ] &&
|
|
65
|
+
tar -C ~/.claude/projects -czf - "./$OLD_ENC" |
|
|
66
|
+
instabox ssh "$BOX" -- "mkdir -p ~/.claude/projects && $X -C ~/.claude/projects"
|
|
67
|
+
|
|
68
|
+
# Codex history: rollout files that recorded this cwd.
|
|
69
|
+
LIST=$(mktemp)
|
|
70
|
+
[ -d ~/.codex/sessions ] && (cd ~/.codex && grep -rlF --include='rollout-*.jsonl' "\"cwd\":\"$SRC\"" sessions) > "$LIST"
|
|
71
|
+
[ -s "$LIST" ] && tar -C ~/.codex -czf - -T "$LIST" |
|
|
72
|
+
instabox ssh "$BOX" -- "mkdir -p ~/.codex && $X -C ~/.codex"
|
|
73
|
+
|
|
74
|
+
# Session ids for step 5, read from the local copy that was sent: a count and the 5 most recent.
|
|
75
|
+
n=$(ls ~/.claude/projects/"$OLD_ENC"/*.jsonl 2>/dev/null | wc -l)
|
|
76
|
+
[ "$n" -gt 0 ] && { echo "Claude: $n sessions, most recent:"
|
|
77
|
+
ls -t ~/.claude/projects/"$OLD_ENC"/*.jsonl | head -n 5 | sed -nE 's;.*/(.*)\.jsonl$;\1;p'; }
|
|
78
|
+
n=$(wc -l < "$LIST")
|
|
79
|
+
[ "$n" -gt 0 ] && { echo "Codex: $n sessions, most recent:" # file names start with the date
|
|
80
|
+
sed -E 's;.*/;;' "$LIST" | sort -r | head -n 5 | sed -nE 's/.*-([0-9a-f-]{36})\.jsonl$/\1/p'; }
|
|
81
|
+
rm -f "$LIST"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Check that the box has the same state. The only allowed difference is `??` lines for the skipped `.env` files, including a whole `?? dir/` line when that directory held only `.env` files:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git status --porcelain; git rev-parse HEAD
|
|
88
|
+
instabox exec "$BOX" --cwd "$DEST" -- 'git status --porcelain; git rev-parse HEAD'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 3. Make the history resumable
|
|
92
|
+
|
|
93
|
+
Link Claude Code's project directory for the new path to the copied one, so `claude --resume` (picker) and `claude --continue` in `$DEST` show the old conversations. Do it before anyone runs `claude` in `$DEST` on the box (it creates the directory otherwise). Nothing is rewritten.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
[ "$OLD_ENC" = "$NEW_ENC" ] || instabox ssh "$BOX" -- "cd ~/.claude/projects &&
|
|
97
|
+
if [ ! -d './$OLD_ENC' ]; then echo 'no Claude Code history copied; nothing to link'
|
|
98
|
+
elif [ -e './$NEW_ENC' ] || [ -L './$NEW_ENC' ]; then ls -ld './$NEW_ENC'; echo 'already exists: link NOT created'
|
|
99
|
+
else ln -s -- '$OLD_ENC' '$NEW_ENC' && echo linked; fi"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
If it prints `already exists: link NOT created` and `$NEW_ENC` is a real directory (someone already ran `claude` in `$DEST`), the old conversations will not show in the picker or `--continue`. Tell the user. `claude --resume <session-id>` still works.
|
|
103
|
+
|
|
104
|
+
Do not write any notes about the move into `CLAUDE.md`, `AGENTS.md` or settings files on the box, global or in the repo.
|
|
105
|
+
|
|
106
|
+
## 4. Install dependencies until it builds
|
|
107
|
+
|
|
108
|
+
There is no fixed recipe. Inspect the project on the box, install, then build and test, and repeat until it builds:
|
|
109
|
+
|
|
110
|
+
1. Look at what the project declares: lockfiles and manifests (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, `requirements*.txt`, `pyproject.toml`/`uv.lock`, `go.mod`, `Cargo.toml`, `Gemfile`, `pom.xml`, ...), toolchain pins (`.nvmrc`, `.node-version`, `.python-version`, `.tool-versions`, `rust-toolchain*`, `go` line in `go.mod`), `Dockerfile`/`docker-compose.yml`, the README and CI config (`.github/workflows`), which often lists the exact setup steps.
|
|
111
|
+
2. Install the matching toolchain versions and the dependencies with the project's own package manager (`sudo apt-get install -y ...` for system libraries).
|
|
112
|
+
3. Run the build and the tests the project defines. Read each failure, install what is missing (a package, a system library, a different toolchain version), and run again.
|
|
113
|
+
|
|
114
|
+
Run short steps (looking at files, checking versions) with `instabox exec "$BOX" --cwd "$DEST" -- '<cmd>'`. It runs `bash -lc` as user `dev`, with sudo. `--timeout` defaults to 60 seconds and cannot go above 600. Installs, builds and test runs can take longer, so run those over `instabox ssh`, which has no time limit and passes the exit status back. Use a login shell so the PATH matches `exec` (`~/go/bin`, `~/.cargo/bin`):
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
instabox ssh "$BOX" -- "cd '$DEST' && bash -l" <<'EOF'
|
|
118
|
+
npm ci && npm run build # the step's commands
|
|
119
|
+
EOF
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The script arrives on stdin, so a command that reads stdin would eat the rest of it. Pass `-y` or `</dev/null` to anything that might prompt. Stop and ask the user when something needs a secret (`.env` values, private registry tokens) or a choice only they can make.
|
|
123
|
+
|
|
124
|
+
## 5. Tell the user
|
|
125
|
+
|
|
126
|
+
- The box name and where the repo is: `instabox ssh <box>`, then `cd /data/workspace/<name>`.
|
|
127
|
+
- What was installed, whether build and tests pass, and anything left to do.
|
|
128
|
+
- Again: git push/pull needs credentials set up on the box. Ignored files and untracked `.env` files were not copied. Secrets in committed or staged files, or in the copied history, did go over.
|
|
129
|
+
- How to pick up old conversations, kept short, with the session ids printed in step 2:
|
|
130
|
+
- Claude Code, in the repo directory on the box: `claude --continue` (latest) or `claude --resume` (picker, or `claude --resume <id>`).
|
|
131
|
+
- Codex keys its picker on the old path: `codex resume <id>` (or pick from `codex resume --all`) and choose "Use current directory"; after that it shows up in plain `codex resume` there too.
|
|
132
|
+
- The local copy is untouched; nothing keeps the two in sync after this.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: instabox-snapshots
|
|
3
|
+
description: Use when the user wants to save an instabox box's state and load it later — "save a snapshot", "checkpoint this box", "restore/load from a snapshot", "start a new box from my setup", "clone this box". Covers instabox snapshot save/ls/files/rm, create --snapshot and fork.
|
|
4
|
+
metadata:
|
|
5
|
+
installed-by: instabox-demo
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# instabox snapshots: save and load
|
|
9
|
+
|
|
10
|
+
A snapshot is a saved copy of a box's disks (root disk and `/data`), private to the account. Load it by creating a new box from it. Snapshots hold disks only: running processes and memory are not saved (use `instabox suspend` for that).
|
|
11
|
+
|
|
12
|
+
## Save
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
instabox snapshot save work --name work-ready --description "deps installed, repo cloned"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- A running box is saved live, without stopping it: the snapshot is `pending` in `instabox snapshot ls` until its disks finish uploading (`--no-wait` returns at once).
|
|
19
|
+
- Do not stop the box first: `stop` ends every process in it. A live snapshot is like a clean power cut: what is on disk is kept, what programs hold only in memory is not. Only if the server answers that this box must be stopped, or if a program must write its data to disk first (a database mid-write), run `instabox stop work`, save, then `instabox start work`.
|
|
20
|
+
- `--root-only` saves just the root disk (smaller; `/data` is not included).
|
|
21
|
+
- A snapshot includes credentials stored under HOME (gh, Claude, Codex, git, SSH keys). Sign out of tools or remove secrets first if the snapshot will be shared or kept long.
|
|
22
|
+
- Pick names that say what is inside (`node20-playwright`, `api-db-seeded`); names must be unique among your snapshots.
|
|
23
|
+
|
|
24
|
+
## Load
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
instabox snapshot ls # your snapshots: id, name, root/data size, source
|
|
28
|
+
instabox create --name work2 --snapshot work-ready --timeout 2h
|
|
29
|
+
instabox exec work2 -- ls /data/home # same root disk and /data as when saved
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--snapshot` takes a snapshot name or its `sn_...` id.
|
|
33
|
+
|
|
34
|
+
## Look inside without starting anything
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
instabox snapshot files work-ready /etc # list a directory of the root disk
|
|
38
|
+
instabox snapshot files work-ready /etc/os-release # print a text file
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This reads the snapshot's root disk only; `/data` (and HOME) are not browsable here. To check files under `/data`, load the snapshot into a box and use `instabox exec`.
|
|
42
|
+
|
|
43
|
+
## Copy a box directly
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
instabox fork work work-copy # copies a stopped box's root and /data; no snapshot kept
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Use `fork` for a one-off copy and `snapshot save` when you will start from the same state again.
|
|
50
|
+
|
|
51
|
+
## Clean up
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
instabox snapshot rm work-ready
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Snapshots count toward the account's snapshot and storage limits (`instabox usage`). Delete ones you no longer need.
|
|
58
|
+
|
|
59
|
+
## Typical agent loop
|
|
60
|
+
|
|
61
|
+
1. `instabox create --name job --snapshot <base> --timeout 2h`
|
|
62
|
+
2. Work with `instabox exec job -- ...`
|
|
63
|
+
3. When the setup is worth keeping: `instabox snapshot save job --name <base>-v2` (no need to stop it)
|
|
64
|
+
4. `instabox rm job` when done.
|