@technotalim-org/console-cli 1.3.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 +10 -0
- package/README.md +48 -0
- package/dist/api.js +56 -0
- package/dist/commands/deploy.js +78 -0
- package/dist/commands/deploys.js +23 -0
- package/dist/commands/init.js +66 -0
- package/dist/commands/login.js +174 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/open.js +20 -0
- package/dist/commands/rollback.js +36 -0
- package/dist/commands/sites.js +16 -0
- package/dist/commands/whoami.js +7 -0
- package/dist/config.js +11 -0
- package/dist/credentials.js +43 -0
- package/dist/index.js +68 -0
- package/dist/pkce.js +14 -0
- package/dist/projectconfig.js +35 -0
- package/dist/prompt.js +26 -0
- package/dist/upload.js +51 -0
- package/dist/util.js +12 -0
- package/dist/walk.js +52 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Copyright (c) 2026 TechnoTaLim. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and its source code are proprietary and confidential. No license,
|
|
4
|
+
express or implied, is granted to use, copy, modify, merge, publish, distribute,
|
|
5
|
+
sublicense, or sell copies of this software except with the prior written
|
|
6
|
+
permission of TechnoTaLim.
|
|
7
|
+
|
|
8
|
+
The npm package "@technotalim/cli" is provided solely to enable customers to
|
|
9
|
+
deploy to TechnoTaLim hosting. Use of the package is subject to the TechnoTaLim
|
|
10
|
+
Terms of Service at https://console.technotalim.com.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @technotalim-org/console-cli
|
|
2
|
+
|
|
3
|
+
Deploy your [TechnoTaLim](https://console.technotalim.com)-hosted websites from the command line.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g @technotalim-org/console-cli
|
|
7
|
+
|
|
8
|
+
technotalim login # authorize this machine in your browser
|
|
9
|
+
technotalim sites:list # see your hosting sites
|
|
10
|
+
technotalim init # (coming) write technotalim.json + .technotalimrc
|
|
11
|
+
technotalim deploy # (coming) ship your site
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Authentication
|
|
15
|
+
|
|
16
|
+
`technotalim login` uses OAuth 2.0 Authorization Code + PKCE over a loopback
|
|
17
|
+
redirect (RFC 8252) — the same pattern as `gh`, `vercel`, and `firebase`. Your
|
|
18
|
+
browser opens the console, you approve in your real session, and a scoped,
|
|
19
|
+
revocable credential is stored at `~/.technotalim/credentials.json` (mode 600).
|
|
20
|
+
|
|
21
|
+
For CI/headless use, generate a token in the console under **CLI & API access**
|
|
22
|
+
and pass it via the `TECHNOTALIM_TOKEN` environment variable.
|
|
23
|
+
|
|
24
|
+
Revoke any credential any time from the console dashboard.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
| Command | Description |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `technotalim login` | Authorize this machine via the browser (PKCE loopback) |
|
|
31
|
+
| `technotalim login --device` | Headless login (device code) for SSH / no-browser machines |
|
|
32
|
+
| `technotalim logout` | Revoke the token and remove local credentials |
|
|
33
|
+
| `technotalim whoami` | Show the signed-in account |
|
|
34
|
+
| `technotalim sites:list` (`sites`) | List your hosting sites |
|
|
35
|
+
| `technotalim init` | Write `technotalim.json` + `.technotalimrc` |
|
|
36
|
+
| `technotalim deploy` | Build, diff, upload changed files, prune — `--force`, `--dry-run`, `--only`, `-m` |
|
|
37
|
+
| `technotalim rollback [id]` | Restore a previous version (`--only`) |
|
|
38
|
+
| `technotalim deploys:list` (`deploys`) | Show recent deploys (`--only`) |
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install
|
|
44
|
+
npm run build
|
|
45
|
+
node dist/index.js whoami
|
|
46
|
+
# point at a dev console:
|
|
47
|
+
TECHNOTALIM_API_BASE=http://localhost:3000 node dist/index.js login
|
|
48
|
+
```
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Authenticated API client. Mints a short-lived access token from the stored
|
|
2
|
+
// cli_token (or the CI env token) and caches it in-process for its lifetime.
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
4
|
+
import { loadCredentials } from './credentials.js';
|
|
5
|
+
/** CI env token takes precedence over the stored interactive credential. */
|
|
6
|
+
export function resolveCliToken() {
|
|
7
|
+
const env = process.env[CONFIG.ciTokenEnv];
|
|
8
|
+
if (env && env.trim())
|
|
9
|
+
return env.trim();
|
|
10
|
+
const c = loadCredentials();
|
|
11
|
+
return c?.cli_token ?? null;
|
|
12
|
+
}
|
|
13
|
+
let cachedAccess = null;
|
|
14
|
+
export async function getAccessToken() {
|
|
15
|
+
if (cachedAccess && cachedAccess.exp - Date.now() > 30_000)
|
|
16
|
+
return cachedAccess.token;
|
|
17
|
+
const cliToken = resolveCliToken();
|
|
18
|
+
if (!cliToken)
|
|
19
|
+
throw new Error('Not logged in. Run `technotalim login`.');
|
|
20
|
+
const res = await fetch(`${CONFIG.apiBase}/api/cli/access`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ cli_token: cliToken }),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const data = (await res.json().catch(() => ({})));
|
|
27
|
+
throw new Error(data.error || `Could not authenticate (HTTP ${res.status}). Try \`technotalim login\` again.`);
|
|
28
|
+
}
|
|
29
|
+
const data = (await res.json());
|
|
30
|
+
cachedAccess = { token: data.access_token, exp: Date.now() + data.expires_in * 1000 };
|
|
31
|
+
return data.access_token;
|
|
32
|
+
}
|
|
33
|
+
export async function apiGet(path) {
|
|
34
|
+
const token = await getAccessToken();
|
|
35
|
+
const res = await fetch(`${CONFIG.apiBase}${path}`, {
|
|
36
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const data = (await res.json().catch(() => ({})));
|
|
40
|
+
throw new Error(data.error || `Request failed (HTTP ${res.status})`);
|
|
41
|
+
}
|
|
42
|
+
return (await res.json());
|
|
43
|
+
}
|
|
44
|
+
export async function apiPost(path, body) {
|
|
45
|
+
const token = await getAccessToken();
|
|
46
|
+
const res = await fetch(`${CONFIG.apiBase}${path}`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const data = (await res.json().catch(() => ({})));
|
|
53
|
+
throw new Error(data.error || `Request failed (HTTP ${res.status})`);
|
|
54
|
+
}
|
|
55
|
+
return (await res.json());
|
|
56
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `technotalim deploy` — build (predeploy), diff against the server, upload
|
|
2
|
+
// only the changed files, then commit (prune + finalize) atomically.
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
import { loadProject } from '../projectconfig.js';
|
|
8
|
+
import { walkPublicDir } from '../walk.js';
|
|
9
|
+
import { uploadFiles } from '../upload.js';
|
|
10
|
+
import { apiPost } from '../api.js';
|
|
11
|
+
import * as prompt from '../prompt.js';
|
|
12
|
+
import { log, fail } from '../util.js';
|
|
13
|
+
export async function deployCommand(opts) {
|
|
14
|
+
const project = loadProject();
|
|
15
|
+
if (!project)
|
|
16
|
+
return fail('No technotalim.json found. Run `technotalim init` first.');
|
|
17
|
+
const { config, rc, root } = project;
|
|
18
|
+
const alias = opts.only || 'default';
|
|
19
|
+
const websiteId = rc.sites?.[alias];
|
|
20
|
+
if (!websiteId)
|
|
21
|
+
return fail(`No site mapped to "${alias}" in .technotalimrc.`);
|
|
22
|
+
const publicDir = resolve(root, config.hosting.public || '.');
|
|
23
|
+
if (!existsSync(publicDir))
|
|
24
|
+
return fail(`Public directory not found: ${config.hosting.public}`);
|
|
25
|
+
// predeploy hooks (e.g. "npm run build").
|
|
26
|
+
for (const cmd of config.hosting.predeploy || []) {
|
|
27
|
+
log.info(pc.dim(`$ ${cmd}`));
|
|
28
|
+
const r = spawnSync(cmd, { cwd: root, stdio: 'inherit', shell: true });
|
|
29
|
+
if (r.status !== 0)
|
|
30
|
+
return fail(`predeploy hook failed: ${cmd}`);
|
|
31
|
+
}
|
|
32
|
+
const files = walkPublicDir(publicDir, config.hosting.ignore || []);
|
|
33
|
+
if (files.length === 0)
|
|
34
|
+
return fail(`No files found in ${config.hosting.public}.`);
|
|
35
|
+
if (!files.some((f) => f.rel === 'index.html')) {
|
|
36
|
+
log.warn('No index.html at the root of your public directory.');
|
|
37
|
+
}
|
|
38
|
+
log.info(`Scanned ${files.length} files. Planning…`);
|
|
39
|
+
const plan = await apiPost('/api/cli/deploy/plan', {
|
|
40
|
+
websiteId,
|
|
41
|
+
files: files.map((f) => ({ path: f.rel, sha256: f.sha256, size: f.size })),
|
|
42
|
+
message: opts.message,
|
|
43
|
+
});
|
|
44
|
+
if (plan.rejected?.length)
|
|
45
|
+
log.warn(`Skipped ${plan.rejected.length} unsafe path(s).`);
|
|
46
|
+
const toUpload = files.filter((f) => plan.upload.includes(f.rel));
|
|
47
|
+
log.info(`${pc.bold(String(toUpload.length))} to upload · ${pc.bold(String(plan.delete.length))} to delete · ${plan.unchanged} unchanged`);
|
|
48
|
+
if (opts.dryRun) {
|
|
49
|
+
plan.upload.forEach((p) => console.log(pc.green(` + ${p}`)));
|
|
50
|
+
plan.delete.forEach((p) => console.log(pc.red(` - ${p}`)));
|
|
51
|
+
log.info('Dry run — nothing deployed.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (toUpload.length === 0 && plan.delete.length === 0) {
|
|
55
|
+
log.success(`Already up to date → ${plan.url}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!opts.force) {
|
|
59
|
+
const ok = await prompt.confirm(`Deploy to ${plan.url}?`, true);
|
|
60
|
+
if (!ok) {
|
|
61
|
+
log.info('Cancelled.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (toUpload.length) {
|
|
66
|
+
await uploadFiles(plan.uid, websiteId, toUpload, (done, total) => {
|
|
67
|
+
process.stdout.write(`\r Uploading ${done}/${total}… `);
|
|
68
|
+
});
|
|
69
|
+
process.stdout.write('\n');
|
|
70
|
+
}
|
|
71
|
+
const commit = await apiPost('/api/cli/deploy/commit', {
|
|
72
|
+
websiteId,
|
|
73
|
+
deployId: plan.deployId,
|
|
74
|
+
manifest: files.map((f) => f.rel),
|
|
75
|
+
spa: !!config.hosting.spa,
|
|
76
|
+
});
|
|
77
|
+
log.success(`Deployed ${files.length} files (${toUpload.length} changed, ${commit.pruned} removed) → ${commit.url}`);
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// `technotalim deploys:list` — recent CLI deploys for the linked site.
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { apiGet } from '../api.js';
|
|
4
|
+
import { loadProject } from '../projectconfig.js';
|
|
5
|
+
import { log, fail } from '../util.js';
|
|
6
|
+
export async function deploysListCommand(opts) {
|
|
7
|
+
const project = loadProject();
|
|
8
|
+
if (!project)
|
|
9
|
+
return fail('No technotalim.json found. Run this in your project directory.');
|
|
10
|
+
const websiteId = project.rc.sites?.[opts.only || 'default'];
|
|
11
|
+
if (!websiteId)
|
|
12
|
+
return fail(`No site mapped to "${opts.only || 'default'}".`);
|
|
13
|
+
const { deploys } = await apiGet(`/api/cli/deploys?websiteId=${encodeURIComponent(websiteId)}`);
|
|
14
|
+
if (!deploys.length) {
|
|
15
|
+
log.info('No deploys yet.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const d of deploys) {
|
|
19
|
+
const when = d.committedAt ? new Date(d.committedAt).toLocaleString() : '—';
|
|
20
|
+
const msg = d.message ? ` ${pc.dim(d.message)}` : '';
|
|
21
|
+
log.info(`${when} ${pc.green('+' + d.uploaded)} ${pc.red('-' + d.pruned)}${msg}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// `technotalim init` — pick a site, detect the public dir, write the project
|
|
2
|
+
// config files.
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { apiGet, resolveCliToken } from '../api.js';
|
|
6
|
+
import { writeProject, defaultIgnore, findUp } from '../projectconfig.js';
|
|
7
|
+
import * as prompt from '../prompt.js';
|
|
8
|
+
import { log } from '../util.js';
|
|
9
|
+
function suggestPublicDir(root) {
|
|
10
|
+
try {
|
|
11
|
+
const pkgPath = join(root, 'package.json');
|
|
12
|
+
if (existsSync(pkgPath)) {
|
|
13
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
14
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
15
|
+
if (deps.vite || deps.astro)
|
|
16
|
+
return 'dist';
|
|
17
|
+
if (deps['react-scripts'])
|
|
18
|
+
return 'build';
|
|
19
|
+
if (deps['@11ty/eleventy'])
|
|
20
|
+
return '_site';
|
|
21
|
+
if (deps['gatsby'])
|
|
22
|
+
return 'public';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
for (const c of ['dist', 'build', 'out', 'public', '_site']) {
|
|
29
|
+
if (existsSync(join(root, c)))
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
return '.';
|
|
33
|
+
}
|
|
34
|
+
export async function initCommand() {
|
|
35
|
+
if (!resolveCliToken()) {
|
|
36
|
+
log.error('Not logged in. Run `technotalim login` first.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const root = process.cwd();
|
|
40
|
+
if (findUp('technotalim.json', root)) {
|
|
41
|
+
const ok = await prompt.confirm('technotalim.json already exists. Overwrite?', false);
|
|
42
|
+
if (!ok) {
|
|
43
|
+
log.info('Cancelled.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const { sites } = await apiGet('/api/cli/sites');
|
|
48
|
+
if (!sites.length) {
|
|
49
|
+
log.error('No hosting sites on your account. Create one in the dashboard first.');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
let siteId;
|
|
53
|
+
if (sites.length === 1) {
|
|
54
|
+
siteId = sites[0].id;
|
|
55
|
+
log.info(`Using your only site: ${sites[0].name} (${sites[0].domain || 'no domain'})`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
siteId = await prompt.select('Which site do you want to deploy to?', sites.map((s) => ({ label: `${s.name} ${s.domain || ''}`.trim(), value: s.id })));
|
|
59
|
+
}
|
|
60
|
+
const suggested = suggestPublicDir(root);
|
|
61
|
+
const publicDir = await prompt.text('What directory contains your built site?', suggested);
|
|
62
|
+
const spa = await prompt.confirm('Single-page app (rewrite all routes to index.html)?', false);
|
|
63
|
+
writeProject(root, { hosting: { public: publicDir, ignore: defaultIgnore(), spa, cleanUrls: false, predeploy: [] } }, { sites: { default: siteId } });
|
|
64
|
+
log.success('Wrote technotalim.json and .technotalimrc');
|
|
65
|
+
log.dim('Run `technotalim deploy` to ship your site.');
|
|
66
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// `technotalim login` — OAuth 2.0 Authorization Code + PKCE over a loopback
|
|
2
|
+
// redirect (RFC 8252). Opens the browser to the console consent page, captures
|
|
3
|
+
// the one-time code on a temporary 127.0.0.1 listener, and exchanges it (with
|
|
4
|
+
// the PKCE verifier) for the long-lived cli_token.
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import open from 'open';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { CONFIG } from '../config.js';
|
|
10
|
+
import { genVerifier, challengeFor, genState } from '../pkce.js';
|
|
11
|
+
import { saveCredentials, credentialsPath } from '../credentials.js';
|
|
12
|
+
import { log, fail } from '../util.js';
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>TechnoTaLim CLI</title><style>body{font-family:system-ui,-apple-system,sans-serif;background:#0b1020;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}.c{text-align:center;padding:40px}h1{font-size:20px;margin:0 0 8px}p{color:#94a3b8;margin:0}</style></head><body><div class="c"><h1>✓ You're signed in</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
|
|
15
|
+
const DENY_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>TechnoTaLim CLI</title></head><body style="font-family:system-ui,sans-serif;text-align:center;padding:60px;color:#334155"><h1>Authorization cancelled</h1><p>You can close this tab.</p></body></html>`;
|
|
16
|
+
export async function loginCommand(opts = {}) {
|
|
17
|
+
if (opts.device)
|
|
18
|
+
return deviceLogin();
|
|
19
|
+
const verifier = genVerifier();
|
|
20
|
+
const challenge = challengeFor(verifier);
|
|
21
|
+
const state = genState();
|
|
22
|
+
const deviceLabel = `${os.userInfo().username}@${os.hostname()}`;
|
|
23
|
+
// Start the loopback listener on a random free port.
|
|
24
|
+
const server = http.createServer();
|
|
25
|
+
const port = await new Promise((resolve) => {
|
|
26
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
|
27
|
+
});
|
|
28
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
29
|
+
const authorizeUrl = new URL(`${CONFIG.apiBase}/cli-authorize`);
|
|
30
|
+
authorizeUrl.searchParams.set('client_id', CONFIG.clientId);
|
|
31
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
32
|
+
authorizeUrl.searchParams.set('scope', CONFIG.scope);
|
|
33
|
+
authorizeUrl.searchParams.set('state', state);
|
|
34
|
+
authorizeUrl.searchParams.set('code_challenge', challenge);
|
|
35
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
36
|
+
authorizeUrl.searchParams.set('device_label', deviceLabel);
|
|
37
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
38
|
+
const timer = setTimeout(() => reject(new Error('Login timed out after 5 minutes.')), 5 * 60_000);
|
|
39
|
+
server.on('request', (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const u = new URL(req.url || '', redirectUri);
|
|
42
|
+
if (u.pathname !== '/callback') {
|
|
43
|
+
res.statusCode = 404;
|
|
44
|
+
res.end('Not found');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const err = u.searchParams.get('error');
|
|
48
|
+
const returnedState = u.searchParams.get('state');
|
|
49
|
+
const code = u.searchParams.get('code');
|
|
50
|
+
if (err) {
|
|
51
|
+
res.setHeader('Content-Type', 'text/html');
|
|
52
|
+
res.end(DENY_HTML);
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(new Error('Authorization was cancelled in the browser.'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (returnedState !== state) {
|
|
58
|
+
res.statusCode = 400;
|
|
59
|
+
res.end('State mismatch');
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
reject(new Error('State mismatch — possible CSRF. Login aborted.'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!code) {
|
|
65
|
+
res.statusCode = 400;
|
|
66
|
+
res.end('Missing code');
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
reject(new Error('No authorization code returned.'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
res.setHeader('Content-Type', 'text/html');
|
|
72
|
+
res.end(SUCCESS_HTML);
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve(code);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
res.statusCode = 500;
|
|
78
|
+
res.end('Error');
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
reject(e);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
log.info('\nOpening your browser to authorize…');
|
|
85
|
+
log.dim(`If it doesn't open, visit:\n${authorizeUrl.toString()}\n`);
|
|
86
|
+
try {
|
|
87
|
+
await open(authorizeUrl.toString());
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* the user can copy the URL above */
|
|
91
|
+
}
|
|
92
|
+
let code;
|
|
93
|
+
try {
|
|
94
|
+
code = await codePromise;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
server.close();
|
|
98
|
+
}
|
|
99
|
+
// Exchange the code + PKCE verifier for the cli_token.
|
|
100
|
+
const res = await fetch(`${CONFIG.apiBase}/api/cli/token`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
grant_type: 'authorization_code',
|
|
105
|
+
client_id: CONFIG.clientId,
|
|
106
|
+
code,
|
|
107
|
+
code_verifier: verifier,
|
|
108
|
+
redirect_uri: redirectUri,
|
|
109
|
+
device_label: deviceLabel,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
const data = (await res.json().catch(() => ({})));
|
|
113
|
+
if (!res.ok || !data.cli_token)
|
|
114
|
+
fail(data.error || 'Token exchange failed.');
|
|
115
|
+
saveCredentials({
|
|
116
|
+
cli_token: data.cli_token,
|
|
117
|
+
apiBase: CONFIG.apiBase,
|
|
118
|
+
createdAt: Date.now(),
|
|
119
|
+
});
|
|
120
|
+
log.success(`Logged in. Credential saved to ${credentialsPath()} (mode 600).`);
|
|
121
|
+
log.dim('Run `technotalim sites:list` to see your sites, or `technotalim init` in a project.');
|
|
122
|
+
}
|
|
123
|
+
// Headless / no-browser-redirect login (RFC 8628 device authorization grant).
|
|
124
|
+
async function deviceLogin() {
|
|
125
|
+
const deviceLabel = `${os.userInfo().username}@${os.hostname()}`;
|
|
126
|
+
const startRes = await fetch(`${CONFIG.apiBase}/api/cli/device/start`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({ client_id: CONFIG.clientId, scope: CONFIG.scope, device_label: deviceLabel }),
|
|
130
|
+
});
|
|
131
|
+
const start = (await startRes.json().catch(() => ({})));
|
|
132
|
+
if (!startRes.ok || !start.device_code)
|
|
133
|
+
fail(start.error || 'Could not start device login.');
|
|
134
|
+
log.info('\nTo authorize this device, open:');
|
|
135
|
+
log.info(` ${pc.bold(start.verification_uri)}`);
|
|
136
|
+
log.info(`and enter the code: ${pc.bold(start.user_code)}\n`);
|
|
137
|
+
try {
|
|
138
|
+
if (start.verification_uri_complete)
|
|
139
|
+
await open(start.verification_uri_complete);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* user can navigate manually */
|
|
143
|
+
}
|
|
144
|
+
const deadline = Date.now() + (start.expires_in || 600) * 1000;
|
|
145
|
+
let interval = (start.interval || 5) * 1000;
|
|
146
|
+
log.dim('Waiting for approval…');
|
|
147
|
+
for (;;) {
|
|
148
|
+
if (Date.now() > deadline)
|
|
149
|
+
fail('Device login timed out. Run `technotalim login --device` again.');
|
|
150
|
+
await sleep(interval);
|
|
151
|
+
const pollRes = await fetch(`${CONFIG.apiBase}/api/cli/device/poll`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ client_id: CONFIG.clientId, device_code: start.device_code }),
|
|
155
|
+
});
|
|
156
|
+
const data = (await pollRes.json().catch(() => ({})));
|
|
157
|
+
if (pollRes.ok && data.cli_token) {
|
|
158
|
+
saveCredentials({ cli_token: data.cli_token, apiBase: CONFIG.apiBase, createdAt: Date.now() });
|
|
159
|
+
log.success(`Logged in. Credential saved to ${credentialsPath()} (mode 600).`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (data.error === 'authorization_pending')
|
|
163
|
+
continue;
|
|
164
|
+
if (data.error === 'slow_down') {
|
|
165
|
+
interval += 5000;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
fail(data.error === 'access_denied'
|
|
169
|
+
? 'Authorization was denied.'
|
|
170
|
+
: data.error === 'expired_token'
|
|
171
|
+
? 'The code expired. Run `technotalim login --device` again.'
|
|
172
|
+
: data.error || 'Login failed.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// `technotalim logout` — revoke the cli_token server-side and remove the
|
|
2
|
+
// local credential file.
|
|
3
|
+
import { CONFIG } from '../config.js';
|
|
4
|
+
import { loadCredentials, clearCredentials } from '../credentials.js';
|
|
5
|
+
import { log } from '../util.js';
|
|
6
|
+
export async function logoutCommand() {
|
|
7
|
+
const c = loadCredentials();
|
|
8
|
+
if (!c) {
|
|
9
|
+
log.info('Not logged in.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await fetch(`${CONFIG.apiBase}/api/cli/logout`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ cli_token: c.cli_token }),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// best-effort revoke — still wipe the local copy
|
|
21
|
+
}
|
|
22
|
+
clearCredentials();
|
|
23
|
+
log.success('Logged out. Token revoked and local credential removed.');
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// `technotalim open` — open the linked site's live URL in the browser.
|
|
2
|
+
import open from 'open';
|
|
3
|
+
import { apiGet } from '../api.js';
|
|
4
|
+
import { loadProject } from '../projectconfig.js';
|
|
5
|
+
import { log, fail } from '../util.js';
|
|
6
|
+
export async function openCommand(opts) {
|
|
7
|
+
const project = loadProject();
|
|
8
|
+
if (!project)
|
|
9
|
+
return fail('No technotalim.json found. Run `technotalim init` first.');
|
|
10
|
+
const websiteId = project.rc.sites?.[opts.only || 'default'];
|
|
11
|
+
if (!websiteId)
|
|
12
|
+
return fail(`No site mapped to "${opts.only || 'default'}".`);
|
|
13
|
+
const { sites } = await apiGet('/api/cli/sites');
|
|
14
|
+
const site = sites.find((s) => s.id === websiteId);
|
|
15
|
+
if (!site || !site.domain)
|
|
16
|
+
return fail('Could not resolve a live URL for this site yet.');
|
|
17
|
+
const url = `https://${site.domain}`;
|
|
18
|
+
log.info(`Opening ${url}`);
|
|
19
|
+
await open(url);
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `technotalim rollback [snapshotId]` — restore a previous version of the site.
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { apiGet, apiPost } from '../api.js';
|
|
4
|
+
import { loadProject } from '../projectconfig.js';
|
|
5
|
+
import * as prompt from '../prompt.js';
|
|
6
|
+
import { log, fail } from '../util.js';
|
|
7
|
+
export async function rollbackCommand(snapshotId, opts) {
|
|
8
|
+
const project = loadProject();
|
|
9
|
+
if (!project)
|
|
10
|
+
return fail('No technotalim.json found. Run this in your project directory.');
|
|
11
|
+
const websiteId = project.rc.sites?.[opts.only || 'default'];
|
|
12
|
+
if (!websiteId)
|
|
13
|
+
return fail(`No site mapped to "${opts.only || 'default'}".`);
|
|
14
|
+
const { snapshots, rollbackEnabled } = await apiGet(`/api/cli/snapshots?websiteId=${encodeURIComponent(websiteId)}`);
|
|
15
|
+
if (!rollbackEnabled)
|
|
16
|
+
return fail("Rollback isn't available on this site's plan.");
|
|
17
|
+
if (!snapshots.length)
|
|
18
|
+
return fail('No restore points available yet.');
|
|
19
|
+
let id = snapshotId;
|
|
20
|
+
if (!id) {
|
|
21
|
+
id = await prompt.select('Choose a restore point:', snapshots.map((s) => ({
|
|
22
|
+
label: `${new Date(s.ts).toLocaleString()} ${pc.dim(s.trigger)} ${pc.dim(s.id)}`,
|
|
23
|
+
value: s.id,
|
|
24
|
+
})));
|
|
25
|
+
}
|
|
26
|
+
else if (!snapshots.some((s) => s.id === id)) {
|
|
27
|
+
return fail(`No snapshot "${id}" found.`);
|
|
28
|
+
}
|
|
29
|
+
const ok = await prompt.confirm(`Restore snapshot ${id}? This replaces the current live files.`, false);
|
|
30
|
+
if (!ok) {
|
|
31
|
+
log.info('Cancelled.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const res = await apiPost('/api/cli/rollback', { websiteId, snapshotId: id });
|
|
35
|
+
log.success(`Restored → ${res.url}`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// `technotalim sites:list` — list the hosting sites the account can deploy to.
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { apiGet } from '../api.js';
|
|
4
|
+
import { log } from '../util.js';
|
|
5
|
+
export async function sitesCommand() {
|
|
6
|
+
const { sites } = await apiGet('/api/cli/sites');
|
|
7
|
+
if (!sites.length) {
|
|
8
|
+
log.info('No hosting sites found on this account.');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
for (const s of sites) {
|
|
12
|
+
const domain = s.domain ? pc.cyan(s.domain) : pc.dim('no domain');
|
|
13
|
+
const status = s.setup ? '' : pc.yellow(' (setup pending)');
|
|
14
|
+
log.info(`${pc.bold(s.name)} ${domain} ${pc.dim(s.id)}${status}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `technotalim whoami` — show the account the CLI is acting as.
|
|
2
|
+
import { apiGet } from '../api.js';
|
|
3
|
+
import { log } from '../util.js';
|
|
4
|
+
export async function whoamiCommand() {
|
|
5
|
+
const me = await apiGet('/api/cli/whoami');
|
|
6
|
+
log.info(`Logged in as ${me.email}`);
|
|
7
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Static configuration for the TechnoTaLim CLI.
|
|
2
|
+
export const CONFIG = {
|
|
3
|
+
// Override for local testing against a dev console.
|
|
4
|
+
apiBase: (process.env.TECHNOTALIM_API_BASE || 'https://console.technotalim.com').replace(/\/+$/, ''),
|
|
5
|
+
// Public OAuth client id (no secret — security comes from PKCE + loopback).
|
|
6
|
+
clientId: 'technotalim-cli',
|
|
7
|
+
scope: 'hosting.deploy',
|
|
8
|
+
// Env var used to pass a CI token in headless environments.
|
|
9
|
+
ciTokenEnv: 'TECHNOTALIM_TOKEN',
|
|
10
|
+
version: '1.3.0',
|
|
11
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Local credential store: ~/.technotalim/credentials.json, mode 0600.
|
|
2
|
+
// Holds the long-lived cli_token. Never world-readable.
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, statSync, rmSync, } from 'fs';
|
|
6
|
+
const DIR = join(homedir(), '.technotalim');
|
|
7
|
+
const FILE = join(DIR, 'credentials.json');
|
|
8
|
+
export function credentialsPath() {
|
|
9
|
+
return FILE;
|
|
10
|
+
}
|
|
11
|
+
export function saveCredentials(c) {
|
|
12
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
13
|
+
writeFileSync(FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
14
|
+
// writeFileSync mode is masked by umask on create — enforce explicitly.
|
|
15
|
+
chmodSync(FILE, 0o600);
|
|
16
|
+
}
|
|
17
|
+
export function loadCredentials() {
|
|
18
|
+
if (!existsSync(FILE))
|
|
19
|
+
return null;
|
|
20
|
+
// Defence: if the file somehow became group/other-readable, tighten it.
|
|
21
|
+
try {
|
|
22
|
+
const st = statSync(FILE);
|
|
23
|
+
if ((st.mode & 0o077) !== 0)
|
|
24
|
+
chmodSync(FILE, 0o600);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* ignore */
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(FILE, 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function clearCredentials() {
|
|
37
|
+
try {
|
|
38
|
+
rmSync(FILE, { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* ignore */
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TechnoTaLim CLI entry point.
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { CONFIG } from './config.js';
|
|
5
|
+
import { loginCommand } from './commands/login.js';
|
|
6
|
+
import { logoutCommand } from './commands/logout.js';
|
|
7
|
+
import { whoamiCommand } from './commands/whoami.js';
|
|
8
|
+
import { sitesCommand } from './commands/sites.js';
|
|
9
|
+
import { initCommand } from './commands/init.js';
|
|
10
|
+
import { deployCommand } from './commands/deploy.js';
|
|
11
|
+
import { rollbackCommand } from './commands/rollback.js';
|
|
12
|
+
import { deploysListCommand } from './commands/deploys.js';
|
|
13
|
+
import { openCommand } from './commands/open.js';
|
|
14
|
+
import { log } from './util.js';
|
|
15
|
+
function run(fn) {
|
|
16
|
+
return async () => {
|
|
17
|
+
try {
|
|
18
|
+
await fn();
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
log.error(e.message);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const program = new Command();
|
|
27
|
+
program
|
|
28
|
+
.name('technotalim')
|
|
29
|
+
.description('Deploy your TechnoTaLim-hosted websites from the command line.')
|
|
30
|
+
.version(CONFIG.version);
|
|
31
|
+
program
|
|
32
|
+
.command('login')
|
|
33
|
+
.description('Authorize this machine via your browser')
|
|
34
|
+
.option('--device', 'Use the device-code flow (no local browser redirect; for SSH/headless)')
|
|
35
|
+
.action((opts) => run(() => loginCommand(opts))());
|
|
36
|
+
program.command('logout').description('Revoke and remove local credentials').action(run(logoutCommand));
|
|
37
|
+
program.command('whoami').description('Show the signed-in account').action(run(whoamiCommand));
|
|
38
|
+
program
|
|
39
|
+
.command('sites:list')
|
|
40
|
+
.alias('sites')
|
|
41
|
+
.description('List your hosting sites')
|
|
42
|
+
.action(run(sitesCommand));
|
|
43
|
+
program.command('init').description('Set up technotalim.json + .technotalimrc in this project').action(run(initCommand));
|
|
44
|
+
program
|
|
45
|
+
.command('deploy')
|
|
46
|
+
.description('Build and deploy your site to TechnoTaLim hosting')
|
|
47
|
+
.option('--only <alias>', 'Deploy a specific site alias from .technotalimrc')
|
|
48
|
+
.option('-m, --message <msg>', 'Annotate this deploy')
|
|
49
|
+
.option('--force', 'Skip the confirmation prompt (for CI)')
|
|
50
|
+
.option('--dry-run', 'Show the change plan without deploying')
|
|
51
|
+
.action((opts) => run(() => deployCommand(opts))());
|
|
52
|
+
program
|
|
53
|
+
.command('rollback [snapshotId]')
|
|
54
|
+
.description('Restore a previous version of the site')
|
|
55
|
+
.option('--only <alias>', 'Site alias from .technotalimrc')
|
|
56
|
+
.action((snapshotId, opts) => run(() => rollbackCommand(snapshotId, opts))());
|
|
57
|
+
program
|
|
58
|
+
.command('deploys:list')
|
|
59
|
+
.alias('deploys')
|
|
60
|
+
.description('Show recent deploys for the linked site')
|
|
61
|
+
.option('--only <alias>', 'Site alias from .technotalimrc')
|
|
62
|
+
.action((opts) => run(() => deploysListCommand(opts))());
|
|
63
|
+
program
|
|
64
|
+
.command('open')
|
|
65
|
+
.description('Open the linked site in your browser')
|
|
66
|
+
.option('--only <alias>', 'Site alias from .technotalimrc')
|
|
67
|
+
.action((opts) => run(() => openCommand(opts))());
|
|
68
|
+
program.parseAsync(process.argv);
|
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// PKCE (RFC 7636) + CSRF state helpers for the login flow.
|
|
2
|
+
import { randomBytes, createHash } from 'crypto';
|
|
3
|
+
/** 43-char base64url verifier (256 bits). */
|
|
4
|
+
export function genVerifier() {
|
|
5
|
+
return randomBytes(32).toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
/** S256 challenge = base64url(sha256(verifier)). */
|
|
8
|
+
export function challengeFor(verifier) {
|
|
9
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
10
|
+
}
|
|
11
|
+
/** Random anti-CSRF state nonce. */
|
|
12
|
+
export function genState() {
|
|
13
|
+
return randomBytes(16).toString('base64url');
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Read/write the project files: technotalim.json (build config) and
|
|
2
|
+
// .technotalimrc (site alias → websiteId).
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
export const CONFIG_FILE = 'technotalim.json';
|
|
6
|
+
export const RC_FILE = '.technotalimrc';
|
|
7
|
+
export function findUp(name, start = process.cwd()) {
|
|
8
|
+
let dir = start;
|
|
9
|
+
for (;;) {
|
|
10
|
+
const p = join(dir, name);
|
|
11
|
+
if (existsSync(p))
|
|
12
|
+
return p;
|
|
13
|
+
const parent = dirname(dir);
|
|
14
|
+
if (parent === dir)
|
|
15
|
+
return null;
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function loadProject() {
|
|
20
|
+
const cfgPath = findUp(CONFIG_FILE);
|
|
21
|
+
if (!cfgPath)
|
|
22
|
+
return null;
|
|
23
|
+
const root = dirname(cfgPath);
|
|
24
|
+
const config = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
25
|
+
const rcPath = join(root, RC_FILE);
|
|
26
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, 'utf8')) : { sites: {} };
|
|
27
|
+
return { config, rc, root };
|
|
28
|
+
}
|
|
29
|
+
export function writeProject(root, config, rc) {
|
|
30
|
+
writeFileSync(join(root, CONFIG_FILE), JSON.stringify(config, null, 2) + '\n');
|
|
31
|
+
writeFileSync(join(root, RC_FILE), JSON.stringify(rc, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
export function defaultIgnore() {
|
|
34
|
+
return ['technotalim.json', '.technotalimrc', 'node_modules', '.git', '.DS_Store', '*.log'];
|
|
35
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Tiny zero-dependency interactive prompts built on Node's readline.
|
|
2
|
+
import { createInterface } from 'readline';
|
|
3
|
+
function ask(question) {
|
|
4
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
5
|
+
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a); }));
|
|
6
|
+
}
|
|
7
|
+
export async function text(message, def) {
|
|
8
|
+
const a = (await ask(`${message}${def ? ` (${def})` : ''}: `)).trim();
|
|
9
|
+
return a || def || '';
|
|
10
|
+
}
|
|
11
|
+
export async function confirm(message, def = false) {
|
|
12
|
+
const a = (await ask(`${message} ${def ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase();
|
|
13
|
+
if (!a)
|
|
14
|
+
return def;
|
|
15
|
+
return a === 'y' || a === 'yes';
|
|
16
|
+
}
|
|
17
|
+
export async function select(message, options) {
|
|
18
|
+
console.log(message);
|
|
19
|
+
options.forEach((o, i) => console.log(` ${i + 1}) ${o.label}`));
|
|
20
|
+
for (;;) {
|
|
21
|
+
const a = (await ask(`Choose [1-${options.length}]: `)).trim();
|
|
22
|
+
const n = parseInt(a, 10);
|
|
23
|
+
if (n >= 1 && n <= options.length)
|
|
24
|
+
return options[n - 1].value;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Stream the changed files to the existing hardened upload service
|
|
2
|
+
// (PUT /api/web-host/files/upload → nginx → Go console-upload). Batched to
|
|
3
|
+
// stay under the service's request-size cap. Reuses the access token.
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { basename } from 'path';
|
|
6
|
+
import { CONFIG } from './config.js';
|
|
7
|
+
import { getAccessToken } from './api.js';
|
|
8
|
+
const MAX_BATCH_BYTES = 40 * 1024 * 1024; // 40 MB per request
|
|
9
|
+
const MAX_BATCH_FILES = 80;
|
|
10
|
+
export async function uploadFiles(uid, websiteId, files, onProgress) {
|
|
11
|
+
const token = await getAccessToken();
|
|
12
|
+
let done = 0;
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < files.length) {
|
|
15
|
+
const batch = [];
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
while (i < files.length &&
|
|
18
|
+
batch.length < MAX_BATCH_FILES &&
|
|
19
|
+
(batch.length === 0 || bytes + files[i].size <= MAX_BATCH_BYTES)) {
|
|
20
|
+
batch.push(files[i]);
|
|
21
|
+
bytes += files[i].size;
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
const fd = new FormData();
|
|
25
|
+
fd.set('userId', uid);
|
|
26
|
+
fd.set('websiteId', websiteId);
|
|
27
|
+
fd.set('path', '');
|
|
28
|
+
fd.set('replaceExisting', 'true');
|
|
29
|
+
fd.set('uploadMode', 'project');
|
|
30
|
+
batch.forEach((f, idx) => {
|
|
31
|
+
const buf = readFileSync(f.abs);
|
|
32
|
+
fd.append(`file-${idx}`, new Blob([buf]), basename(f.rel));
|
|
33
|
+
fd.append(`file-${idx}-path`, f.rel);
|
|
34
|
+
});
|
|
35
|
+
const res = await fetch(`${CONFIG.apiBase}/api/web-host/files/upload`, {
|
|
36
|
+
method: 'PUT',
|
|
37
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
38
|
+
body: fd,
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const data = (await res.json().catch(() => ({})));
|
|
42
|
+
throw new Error(data.error || `Upload failed (HTTP ${res.status})`);
|
|
43
|
+
}
|
|
44
|
+
const data = (await res.json());
|
|
45
|
+
if (Array.isArray(data.failedFiles) && data.failedFiles.length) {
|
|
46
|
+
throw new Error(`Some files were rejected: ${JSON.stringify(data.failedFiles).slice(0, 300)}`);
|
|
47
|
+
}
|
|
48
|
+
done += batch.length;
|
|
49
|
+
onProgress?.(done, files.length);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
export const log = {
|
|
3
|
+
info: (m) => console.log(m),
|
|
4
|
+
success: (m) => console.log(pc.green('✓ ') + m),
|
|
5
|
+
warn: (m) => console.log(pc.yellow('! ') + m),
|
|
6
|
+
error: (m) => console.error(pc.red('✗ ') + m),
|
|
7
|
+
dim: (m) => console.log(pc.dim(m)),
|
|
8
|
+
};
|
|
9
|
+
export function fail(msg) {
|
|
10
|
+
log.error(msg);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
package/dist/walk.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Walk a public directory into a deploy manifest, honouring ignore rules and
|
|
2
|
+
// refusing to follow symlinks.
|
|
3
|
+
import { readdirSync, readFileSync, lstatSync } from 'fs';
|
|
4
|
+
import { join, relative, sep } from 'path';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import ignore from 'ignore';
|
|
7
|
+
export function walkPublicDir(publicDir, ignorePatterns) {
|
|
8
|
+
const ig = ignore().add(ignorePatterns);
|
|
9
|
+
const files = [];
|
|
10
|
+
const recurse = (dir) => {
|
|
11
|
+
let names;
|
|
12
|
+
try {
|
|
13
|
+
names = readdirSync(dir);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
const abs = join(dir, name);
|
|
20
|
+
let lst;
|
|
21
|
+
try {
|
|
22
|
+
lst = lstatSync(abs);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (lst.isSymbolicLink())
|
|
28
|
+
continue; // never deploy symlinks
|
|
29
|
+
const rel = relative(publicDir, abs).split(sep).join('/');
|
|
30
|
+
if (!rel)
|
|
31
|
+
continue;
|
|
32
|
+
if (lst.isDirectory()) {
|
|
33
|
+
if (ig.ignores(rel) || ig.ignores(rel + '/'))
|
|
34
|
+
continue;
|
|
35
|
+
recurse(abs);
|
|
36
|
+
}
|
|
37
|
+
else if (lst.isFile()) {
|
|
38
|
+
if (ig.ignores(rel))
|
|
39
|
+
continue;
|
|
40
|
+
const buf = readFileSync(abs);
|
|
41
|
+
files.push({
|
|
42
|
+
rel,
|
|
43
|
+
abs,
|
|
44
|
+
size: buf.length,
|
|
45
|
+
sha256: createHash('sha256').update(buf).digest('hex'),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
recurse(publicDir);
|
|
51
|
+
return files;
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@technotalim-org/console-cli",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Deploy your TechnoTaLim-hosted websites from the command line.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"technotalim": "dist/index.js",
|
|
8
|
+
"tt": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"technotalim",
|
|
16
|
+
"hosting",
|
|
17
|
+
"deploy",
|
|
18
|
+
"static-site",
|
|
19
|
+
"cli"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://console.technotalim.com",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/TechnoTaLim/technotalim-cli.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/TechnoTaLim/technotalim-cli/issues"
|
|
28
|
+
},
|
|
29
|
+
"author": "TechnoTaLim",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.12.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"commander": "^15.0.0",
|
|
43
|
+
"ignore": "^7.0.5",
|
|
44
|
+
"open": "^11.0.0",
|
|
45
|
+
"picocolors": "^1.1.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"typescript": "^6.0.3"
|
|
50
|
+
},
|
|
51
|
+
"license": "UNLICENSED"
|
|
52
|
+
}
|