coursecode 0.1.9 → 0.1.11
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/bin/cli.js +48 -13
- package/framework/css/01-base.css +24 -0
- package/framework/css/design-tokens.css +8 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1 -1
- package/framework/docs/USER_GUIDE.md +17 -6
- package/lib/cloud-certs.js +44 -55
- package/lib/cloud.js +268 -93
- package/package.json +4 -3
package/bin/cli.js
CHANGED
|
@@ -21,23 +21,23 @@ import path from 'path';
|
|
|
21
21
|
import fs from 'fs';
|
|
22
22
|
|
|
23
23
|
// =============================================================================
|
|
24
|
-
// CORPORATE NETWORK: System CA cert
|
|
24
|
+
// CORPORATE NETWORK: System CA cert injection
|
|
25
25
|
//
|
|
26
26
|
// On corporate machines with SSL-inspecting proxies (e.g. Zscaler), the proxy
|
|
27
27
|
// presents its own CA certificate. Node.js ships its own CA bundle and ignores
|
|
28
28
|
// the OS trust store, so TLS verification fails.
|
|
29
29
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// The NODE_EXTRA_CA_CERTS guard prevents an infinite re-exec loop.
|
|
30
|
+
// Windows: win-ca injects certs directly into Node's TLS context (in-process,
|
|
31
|
+
// no subprocess, no re-exec). Works regardless of PowerShell policy.
|
|
32
|
+
// macOS/Linux: exports OS certs to a temp PEM file and re-execs with
|
|
33
|
+
// NODE_EXTRA_CA_CERTS. The guard prevents an infinite loop.
|
|
35
34
|
// =============================================================================
|
|
36
35
|
|
|
37
36
|
if (!process.env.NODE_EXTRA_CA_CERTS) {
|
|
38
|
-
const {
|
|
39
|
-
const certPath = await
|
|
37
|
+
const { injectSystemCerts } = await import('../lib/cloud-certs.js');
|
|
38
|
+
const certPath = await injectSystemCerts();
|
|
40
39
|
if (certPath) {
|
|
40
|
+
// macOS/Linux: re-exec with NODE_EXTRA_CA_CERTS pointing to PEM file
|
|
41
41
|
const { execFileSync } = await import('child_process');
|
|
42
42
|
execFileSync(process.execPath, process.argv.slice(1), {
|
|
43
43
|
env: { ...process.env, NODE_EXTRA_CA_CERTS: certPath },
|
|
@@ -45,6 +45,7 @@ if (!process.env.NODE_EXTRA_CA_CERTS) {
|
|
|
45
45
|
});
|
|
46
46
|
process.exit(0);
|
|
47
47
|
}
|
|
48
|
+
// Windows: win-ca already injected certs in-process — continue normally
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -316,10 +317,11 @@ program
|
|
|
316
317
|
.command('logout')
|
|
317
318
|
.description('Log out of CourseCode Cloud')
|
|
318
319
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
320
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
319
321
|
.action(async (options) => {
|
|
320
322
|
const { logout, setLocalMode } = await import('../lib/cloud.js');
|
|
321
323
|
if (options.local) setLocalMode();
|
|
322
|
-
await logout();
|
|
324
|
+
await logout({ json: options.json });
|
|
323
325
|
});
|
|
324
326
|
|
|
325
327
|
program
|
|
@@ -337,33 +339,66 @@ program
|
|
|
337
339
|
.command('courses')
|
|
338
340
|
.description('List courses on CourseCode Cloud')
|
|
339
341
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
342
|
+
.option('--json', 'Output raw JSON array')
|
|
340
343
|
.action(async (options) => {
|
|
341
344
|
const { listCourses, setLocalMode } = await import('../lib/cloud.js');
|
|
342
345
|
if (options.local) setLocalMode();
|
|
343
|
-
await listCourses();
|
|
346
|
+
await listCourses({ json: options.json });
|
|
344
347
|
});
|
|
345
348
|
|
|
346
349
|
program
|
|
347
350
|
.command('deploy')
|
|
348
351
|
.description('Build and deploy course to CourseCode Cloud')
|
|
349
|
-
.option('--preview', 'Deploy as preview (
|
|
350
|
-
.option('--
|
|
352
|
+
.option('--preview', 'Deploy as preview-only (production untouched, preview pointer always moved). Combine with --promote or --stage for a full deploy that also moves the preview pointer.')
|
|
353
|
+
.option('--promote', 'Force-promote: always move production pointer regardless of deploy_mode setting. Mutually exclusive with --stage.')
|
|
354
|
+
.option('--stage', 'Force-stage: never move production pointer regardless of deploy_mode setting. Mutually exclusive with --promote.')
|
|
355
|
+
.option('--password', 'Password-protect preview (interactive prompt, requires --preview)')
|
|
351
356
|
.option('-m, --message <message>', 'Deploy reason (e.g. "Fixed accessibility issues")')
|
|
352
357
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
358
|
+
.option('--json', 'Emit machine-readable JSON result')
|
|
353
359
|
.action(async (options) => {
|
|
354
360
|
const { deploy, setLocalMode } = await import('../lib/cloud.js');
|
|
355
361
|
if (options.local) setLocalMode();
|
|
356
362
|
await deploy(options);
|
|
357
363
|
});
|
|
358
364
|
|
|
365
|
+
program
|
|
366
|
+
.command('promote')
|
|
367
|
+
.description('Promote a deployment to production or preview pointer')
|
|
368
|
+
.option('--production', 'Promote to the production pointer')
|
|
369
|
+
.option('--preview', 'Promote to the preview pointer')
|
|
370
|
+
.option('--deployment <id>', 'Deployment ID to promote (skip interactive prompt)')
|
|
371
|
+
.option('-m, --message <message>', 'Reason for promotion')
|
|
372
|
+
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
373
|
+
.option('--json', 'Emit machine-readable JSON result')
|
|
374
|
+
.action(async (options) => {
|
|
375
|
+
const { promote, setLocalMode } = await import('../lib/cloud.js');
|
|
376
|
+
if (options.local) setLocalMode();
|
|
377
|
+
await promote(options);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
|
|
359
381
|
program
|
|
360
382
|
.command('status')
|
|
361
383
|
.description('Show deployment status for current course')
|
|
362
384
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
385
|
+
.option('--json', 'Output raw JSON')
|
|
363
386
|
.action(async (options) => {
|
|
364
387
|
const { status, setLocalMode } = await import('../lib/cloud.js');
|
|
365
388
|
if (options.local) setLocalMode();
|
|
366
|
-
await status();
|
|
389
|
+
await status({ json: options.json });
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
program
|
|
393
|
+
.command('delete')
|
|
394
|
+
.description('Remove course from CourseCode Cloud (does not delete local files)')
|
|
395
|
+
.option('--force', 'Skip confirmation prompt')
|
|
396
|
+
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
397
|
+
.option('--json', 'Emit machine-readable JSON result')
|
|
398
|
+
.action(async (options) => {
|
|
399
|
+
const { deleteCourse, setLocalMode } = await import('../lib/cloud.js');
|
|
400
|
+
if (options.local) setLocalMode();
|
|
401
|
+
await deleteCourse({ force: options.force, json: options.json });
|
|
367
402
|
});
|
|
368
403
|
|
|
369
404
|
program.parse();
|
|
@@ -136,6 +136,30 @@ em, i {
|
|
|
136
136
|
color: var(--color-gray-800);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/* Styled Scrollbars (cross-platform) */
|
|
140
|
+
html {
|
|
141
|
+
scrollbar-width: thin;
|
|
142
|
+
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
::-webkit-scrollbar {
|
|
146
|
+
width: 8px;
|
|
147
|
+
height: 8px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
::-webkit-scrollbar-track {
|
|
151
|
+
background: var(--scrollbar-track);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
::-webkit-scrollbar-thumb {
|
|
155
|
+
background: var(--scrollbar-thumb);
|
|
156
|
+
border-radius: 4px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
::-webkit-scrollbar-thumb:hover {
|
|
160
|
+
background: var(--scrollbar-thumb-hover);
|
|
161
|
+
}
|
|
162
|
+
|
|
139
163
|
/* Link Styles */
|
|
140
164
|
a {
|
|
141
165
|
color: var(--color-primary);
|
|
@@ -441,6 +441,11 @@
|
|
|
441
441
|
--sidebar-scrollbar-thumb-hover: var(--color-gray-400); /* Sidebar scrollbar thumb (hover) */
|
|
442
442
|
--sidebar-backdrop: var(--bg-overlay); /* Sidebar overlay backdrop color */
|
|
443
443
|
|
|
444
|
+
/* Scrollbar Tokens (global, content area) */
|
|
445
|
+
--scrollbar-thumb: var(--color-gray-300); /* Scrollbar thumb */
|
|
446
|
+
--scrollbar-thumb-hover: var(--color-gray-400); /* Scrollbar thumb (hover) */
|
|
447
|
+
--scrollbar-track: transparent; /* Scrollbar track background */
|
|
448
|
+
|
|
444
449
|
/* Footer Tokens */
|
|
445
450
|
--footer-bg: var(--bg-surface); /* Footer background */
|
|
446
451
|
--footer-text: var(--text-primary); /* Footer text color */
|
|
@@ -678,6 +683,9 @@
|
|
|
678
683
|
--sidebar-scrollbar-thumb: var(--color-gray-500);
|
|
679
684
|
--sidebar-scrollbar-thumb-hover: var(--color-gray-400);
|
|
680
685
|
--sidebar-backdrop: color-mix(in srgb, var(--palette-black) 38%, transparent);
|
|
686
|
+
--scrollbar-thumb: var(--color-gray-500);
|
|
687
|
+
--scrollbar-thumb-hover: var(--color-gray-400);
|
|
688
|
+
--scrollbar-track: transparent;
|
|
681
689
|
--footer-bg: var(--bg-surface);
|
|
682
690
|
--footer-text: var(--text-primary);
|
|
683
691
|
--footer-border: var(--border-default);
|
|
@@ -123,7 +123,7 @@ The browser only downloads the one chunk matching the meta tag. Unused driver ch
|
|
|
123
123
|
| `coursecode build` | `dist/` | Universal build + format manifest + meta tag stamped |
|
|
124
124
|
| `coursecode build` (with `PACKAGE=true`) | `dist/` + ZIP | Same + format-specific ZIP for LMS upload |
|
|
125
125
|
| `coursecode preview --export` | `course-preview/` | Copy of `dist/` wrapped in stub player (for Netlify/GitHub Pages) |
|
|
126
|
-
| `coursecode deploy` | Uploads `dist/` | Cloud hosts universal build, assembles format ZIPs on demand |
|
|
126
|
+
| `coursecode deploy` | Uploads `dist/` | Cloud hosts universal build, assembles format ZIPs on demand. Flags: `--promote` (force live), `--stage` (force staged), `--preview` (preview-only: production untouched, preview always moved). `--promote`/`--stage` are mutually exclusive; `--preview` stacks with either. |
|
|
127
127
|
|
|
128
128
|
The ZIP never includes preview/stub player assets. Preview is a separate concern (see below).
|
|
129
129
|
|
|
@@ -723,12 +723,23 @@ Running `coursecode login` displays a URL and a short code in your terminal:
|
|
|
723
723
|
|
|
724
724
|
Open the URL in any browser, log in with your CourseCode account, and enter the code. The terminal confirms login automatically — no redirect back required. The code is valid for 15 minutes and works from any device or browser.
|
|
725
725
|
|
|
726
|
-
**
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
726
|
+
**Deploy flags:**
|
|
727
|
+
|
|
728
|
+
`coursecode deploy` accepts flags that control how the production and preview pointers are updated after upload:
|
|
729
|
+
|
|
730
|
+
| Command | Production pointer | Preview pointer |
|
|
731
|
+
|---|---|---|
|
|
732
|
+
| `cc deploy` | Follows your deploy_mode setting | Follows your preview_deploy_mode setting |
|
|
733
|
+
| `cc deploy --promote` | Always moved to new version | Follows your preview_deploy_mode setting |
|
|
734
|
+
| `cc deploy --stage` | Never moved (stays on old version) | Follows your preview_deploy_mode setting |
|
|
735
|
+
| `cc deploy --preview` | **Untouched** (preview-only upload) | Always moved to new version |
|
|
736
|
+
| `cc deploy --promote --preview` | Always moved to new version | Always moved to new version |
|
|
737
|
+
| `cc deploy --stage --preview` | Never moved | Always moved to new version |
|
|
738
|
+
|
|
739
|
+
- **Production pointer** — the version learners see when they launch your course.
|
|
740
|
+
- **Preview pointer** — the version served on the cloud preview link (for stakeholder review).
|
|
741
|
+
- **deploy_mode** — a per-course or org setting in the Cloud dashboard. Default is auto-promote (new uploads immediately go live). Can be set to staged (new uploads require a manual promote step).
|
|
742
|
+
- `--promote` and `--stage` are mutually exclusive.
|
|
732
743
|
|
|
733
744
|
**Typical Cloud workflow:**
|
|
734
745
|
1. Run `coursecode login` once, open the URL shown, and enter the code.
|
package/lib/cloud-certs.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cloud-certs.js — System CA certificate
|
|
2
|
+
* cloud-certs.js — System CA certificate injection for corporate network compatibility.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* On corporate machines with SSL-inspecting proxies (e.g. Zscaler), the proxy
|
|
5
|
+
* presents its own CA certificate. Node.js ships its own CA bundle and ignores
|
|
6
|
+
* the OS trust store, causing TLS verification failures.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Platform strategy:
|
|
9
|
+
* - Windows: `win-ca` (native N-API addon, calls Windows CryptoAPI directly)
|
|
10
|
+
* - macOS: `security` CLI exports system keychains to PEM
|
|
11
|
+
* - Linux: reads well-known CA bundle file paths
|
|
12
|
+
*
|
|
13
|
+
* On macOS/Linux, returns a PEM file path for NODE_EXTRA_CA_CERTS.
|
|
14
|
+
* On Windows, injects certs directly into Node's TLS context (no file needed).
|
|
15
|
+
* Never throws — silent no-op on non-corporate machines.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
import { execFile } from 'child_process';
|
|
12
19
|
import { promisify } from 'util';
|
|
20
|
+
import { createRequire } from 'module';
|
|
13
21
|
import fs from 'fs';
|
|
14
22
|
import os from 'os';
|
|
15
23
|
import path from 'path';
|
|
@@ -17,25 +25,34 @@ import crypto from 'crypto';
|
|
|
17
25
|
|
|
18
26
|
const execFileAsync = promisify(execFile);
|
|
19
27
|
|
|
20
|
-
/** Cached result — only
|
|
21
|
-
let
|
|
28
|
+
/** Cached result — only run once per process lifetime. */
|
|
29
|
+
let _applied = false;
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
|
-
*
|
|
32
|
+
* Inject the OS system root certificates into Node's TLS context.
|
|
33
|
+
*
|
|
34
|
+
* On Windows, win-ca patches Node's TLS in-process (no file needed, no re-exec).
|
|
35
|
+
* On macOS/Linux, returns a PEM file path for NODE_EXTRA_CA_CERTS re-exec.
|
|
25
36
|
*
|
|
26
|
-
* @returns {Promise<string|null>}
|
|
37
|
+
* @returns {Promise<string|null>} PEM file path (macOS/Linux) or null (Windows/unavailable).
|
|
27
38
|
*/
|
|
28
|
-
export async function
|
|
29
|
-
if (
|
|
39
|
+
export async function injectSystemCerts() {
|
|
40
|
+
if (_applied) return null;
|
|
41
|
+
_applied = true;
|
|
30
42
|
|
|
31
43
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
_cachedCertPath = null;
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
injectWindowsCerts();
|
|
35
46
|
return null;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
//
|
|
49
|
+
// macOS/Linux: export to PEM file for NODE_EXTRA_CA_CERTS
|
|
50
|
+
const pem = process.platform === 'darwin'
|
|
51
|
+
? await readMacosCerts()
|
|
52
|
+
: readLinuxCerts();
|
|
53
|
+
|
|
54
|
+
if (!pem || !pem.trim()) return null;
|
|
55
|
+
|
|
39
56
|
const hash = crypto.createHash('sha1').update(pem).digest('hex').slice(0, 8);
|
|
40
57
|
const certPath = path.join(os.tmpdir(), `coursecode-ca-${hash}.pem`);
|
|
41
58
|
|
|
@@ -43,31 +60,26 @@ export async function exportSystemCerts() {
|
|
|
43
60
|
fs.writeFileSync(certPath, pem, { mode: 0o600 });
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
_cachedCertPath = certPath;
|
|
47
63
|
return certPath;
|
|
48
64
|
} catch {
|
|
49
|
-
_cachedCertPath = null;
|
|
50
65
|
return null;
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
70
|
+
* Windows: inject system root certs via win-ca.
|
|
71
|
+
*
|
|
72
|
+
* win-ca is a native N-API addon that calls Windows CryptoAPI directly.
|
|
73
|
+
* No PowerShell, no subprocesses, no temp files. Works regardless of
|
|
74
|
+
* execution policy, AppLocker, or PowerShell availability.
|
|
75
|
+
*
|
|
76
|
+
* The { inject: '+' } mode patches tls.createSecureContext() so system
|
|
77
|
+
* certs are used *in addition to* Node's built-in CA bundle.
|
|
57
78
|
*/
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return readMacosCerts();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (platform === 'win32') {
|
|
66
|
-
return readWindowsCerts();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Linux/other: read well-known bundle paths directly
|
|
70
|
-
return readLinuxCerts();
|
|
79
|
+
function injectWindowsCerts() {
|
|
80
|
+
const require = createRequire(import.meta.url);
|
|
81
|
+
const winCa = require('win-ca/api');
|
|
82
|
+
winCa({ inject: '+' });
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
/**
|
|
@@ -75,7 +87,6 @@ async function readSystemCerts() {
|
|
|
75
87
|
* This includes all roots installed via Apple MDM / System Preferences.
|
|
76
88
|
*/
|
|
77
89
|
async function readMacosCerts() {
|
|
78
|
-
// Export from all common keychains — ignore errors on missing keychains
|
|
79
90
|
const keychains = [
|
|
80
91
|
'/Library/Keychains/SystemRootCertificates.keychain',
|
|
81
92
|
'/System/Library/Keychains/SystemRootCertificates.keychain',
|
|
@@ -97,28 +108,6 @@ async function readMacosCerts() {
|
|
|
97
108
|
return pems.join('\n');
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
/**
|
|
101
|
-
* Windows: export LocalMachine\Root via PowerShell.
|
|
102
|
-
* This includes certs deployed via Group Policy / MDM.
|
|
103
|
-
*/
|
|
104
|
-
async function readWindowsCerts() {
|
|
105
|
-
const script = [
|
|
106
|
-
'$certs = Get-ChildItem -Path Cert:\\LocalMachine\\Root',
|
|
107
|
-
'$pems = $certs | ForEach-Object {',
|
|
108
|
-
' $bytes = $_.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)',
|
|
109
|
-
' $b64 = [System.Convert]::ToBase64String($bytes, "InsertLineBreaks")',
|
|
110
|
-
' "-----BEGIN CERTIFICATE-----`n" + $b64 + "`n-----END CERTIFICATE-----"',
|
|
111
|
-
'}',
|
|
112
|
-
'$pems -join "`n"',
|
|
113
|
-
].join('; ');
|
|
114
|
-
|
|
115
|
-
const { stdout } = await execFileAsync('powershell', [
|
|
116
|
-
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
117
|
-
], { maxBuffer: 16 * 1024 * 1024 });
|
|
118
|
-
|
|
119
|
-
return stdout;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
111
|
/**
|
|
123
112
|
* Linux: read the system CA bundle from well-known locations.
|
|
124
113
|
* No subprocess needed — just read the file directly.
|
package/lib/cloud.js
CHANGED
|
@@ -31,8 +31,6 @@ const LOCAL_CLOUD_URL = 'http://localhost:3000';
|
|
|
31
31
|
let useLocal = false;
|
|
32
32
|
const CREDENTIALS_DIR = path.join(os.homedir(), '.coursecode');
|
|
33
33
|
const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
34
|
-
const PROJECT_CONFIG_DIR = '.coursecode';
|
|
35
|
-
const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'project.json');
|
|
36
34
|
|
|
37
35
|
const POLL_INTERVAL_MS = 2000;
|
|
38
36
|
const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes (device code expiry)
|
|
@@ -110,29 +108,6 @@ export function setLocalMode() {
|
|
|
110
108
|
useLocal = true;
|
|
111
109
|
}
|
|
112
110
|
|
|
113
|
-
// =============================================================================
|
|
114
|
-
// PROJECT BINDING (local: .coursecode/project.json)
|
|
115
|
-
// =============================================================================
|
|
116
|
-
|
|
117
|
-
function readProjectConfig() {
|
|
118
|
-
try {
|
|
119
|
-
const fullPath = path.join(process.cwd(), PROJECT_CONFIG_PATH);
|
|
120
|
-
if (!fs.existsSync(fullPath)) return null;
|
|
121
|
-
return JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
|
122
|
-
} catch {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function writeProjectConfig(data) {
|
|
128
|
-
const dir = path.join(process.cwd(), PROJECT_CONFIG_DIR);
|
|
129
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
-
fs.writeFileSync(
|
|
131
|
-
path.join(process.cwd(), PROJECT_CONFIG_PATH),
|
|
132
|
-
JSON.stringify(data, null, 2) + '\n'
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
111
|
// =============================================================================
|
|
137
112
|
// COURSE IDENTITY (committed: .coursecoderc.json → cloudId)
|
|
138
113
|
// =============================================================================
|
|
@@ -151,12 +126,13 @@ function readRcConfig() {
|
|
|
151
126
|
}
|
|
152
127
|
|
|
153
128
|
/**
|
|
154
|
-
*
|
|
129
|
+
* Merge fields into .coursecoderc.json without clobbering unrelated fields.
|
|
130
|
+
* Use this for any cloud binding state (cloudId, orgId, etc.).
|
|
155
131
|
*/
|
|
156
|
-
function
|
|
132
|
+
function writeRcConfig(fields) {
|
|
157
133
|
const rcPath = path.join(process.cwd(), '.coursecoderc.json');
|
|
158
134
|
const existing = readRcConfig() || {};
|
|
159
|
-
existing
|
|
135
|
+
Object.assign(existing, fields);
|
|
160
136
|
fs.writeFileSync(rcPath, JSON.stringify(existing, null, 2) + '\n');
|
|
161
137
|
}
|
|
162
138
|
|
|
@@ -330,7 +306,7 @@ function openBrowser(url) {
|
|
|
330
306
|
const platform = process.platform;
|
|
331
307
|
const cmd = platform === 'darwin' ? 'open'
|
|
332
308
|
: platform === 'win32' ? 'start'
|
|
333
|
-
|
|
309
|
+
: 'xdg-open';
|
|
334
310
|
exec(`${cmd} "${url}"`);
|
|
335
311
|
}
|
|
336
312
|
|
|
@@ -552,22 +528,11 @@ export async function ensureAuthenticated() {
|
|
|
552
528
|
* Returns { orgId, courseId, orgName } or prompts the user.
|
|
553
529
|
*/
|
|
554
530
|
async function resolveOrgAndCourse(slug, token) {
|
|
555
|
-
//
|
|
531
|
+
// .coursecoderc.json is committed and shared — it is the single source of truth
|
|
532
|
+
// for the cloud binding. If both cloudId and orgId are present, short-circuit.
|
|
556
533
|
const rcConfig = readRcConfig();
|
|
557
|
-
if (rcConfig?.cloudId) {
|
|
558
|
-
|
|
559
|
-
const projectConfig = readProjectConfig();
|
|
560
|
-
if (projectConfig?.orgId) {
|
|
561
|
-
return { orgId: projectConfig.orgId, courseId: rcConfig.cloudId };
|
|
562
|
-
}
|
|
563
|
-
// Have cloudId but no orgId — fall through to API resolution
|
|
564
|
-
// which will match on courseId
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// 2. Check cached project config (gitignored, per-developer)
|
|
568
|
-
const projectConfig = readProjectConfig();
|
|
569
|
-
if (projectConfig?.orgId && projectConfig?.courseId) {
|
|
570
|
-
return { orgId: projectConfig.orgId, courseId: projectConfig.courseId };
|
|
534
|
+
if (rcConfig?.cloudId && rcConfig?.orgId) {
|
|
535
|
+
return { orgId: rcConfig.orgId, courseId: rcConfig.cloudId };
|
|
571
536
|
}
|
|
572
537
|
|
|
573
538
|
// Call resolve endpoint
|
|
@@ -576,8 +541,7 @@ async function resolveOrgAndCourse(slug, token) {
|
|
|
576
541
|
|
|
577
542
|
// Found in exactly one org
|
|
578
543
|
if (data.found) {
|
|
579
|
-
|
|
580
|
-
writeProjectConfig(binding);
|
|
544
|
+
writeRcConfig({ orgId: data.orgId, cloudId: data.courseId });
|
|
581
545
|
return { orgId: data.orgId, courseId: data.courseId, orgName: data.orgName };
|
|
582
546
|
}
|
|
583
547
|
|
|
@@ -594,8 +558,7 @@ async function resolveOrgAndCourse(slug, token) {
|
|
|
594
558
|
process.exit(1);
|
|
595
559
|
}
|
|
596
560
|
const match = data.matches[idx];
|
|
597
|
-
|
|
598
|
-
writeProjectConfig(binding);
|
|
561
|
+
writeRcConfig({ orgId: match.orgId, cloudId: match.courseId });
|
|
599
562
|
return { orgId: match.orgId, courseId: match.courseId, orgName: match.orgName };
|
|
600
563
|
}
|
|
601
564
|
|
|
@@ -677,14 +640,14 @@ export async function login(options = {}) {
|
|
|
677
640
|
/**
|
|
678
641
|
* coursecode logout — delete credentials and local project.json
|
|
679
642
|
*/
|
|
680
|
-
export async function logout() {
|
|
643
|
+
export async function logout(options = {}) {
|
|
681
644
|
deleteCredentials();
|
|
682
645
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
646
|
+
if (options.json) {
|
|
647
|
+
process.stdout.write(JSON.stringify({ success: true }) + '\n');
|
|
648
|
+
} else {
|
|
649
|
+
console.log('\n✓ Logged out of CourseCode Cloud.\n');
|
|
650
|
+
}
|
|
688
651
|
}
|
|
689
652
|
|
|
690
653
|
/**
|
|
@@ -719,7 +682,7 @@ export async function whoami(options = {}) {
|
|
|
719
682
|
/**
|
|
720
683
|
* coursecode courses — list courses across all orgs
|
|
721
684
|
*/
|
|
722
|
-
export async function listCourses() {
|
|
685
|
+
export async function listCourses(options = {}) {
|
|
723
686
|
await ensureAuthenticated();
|
|
724
687
|
|
|
725
688
|
const makeRequest = async (_isRetry = false) => {
|
|
@@ -730,6 +693,11 @@ export async function listCourses() {
|
|
|
730
693
|
|
|
731
694
|
const courses = await makeRequest();
|
|
732
695
|
|
|
696
|
+
if (options.json) {
|
|
697
|
+
process.stdout.write(JSON.stringify(courses) + '\n');
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
733
701
|
if (!courses.length) {
|
|
734
702
|
console.log('\n No courses found. Deploy one with: coursecode deploy\n');
|
|
735
703
|
return;
|
|
@@ -763,8 +731,24 @@ export async function deploy(options = {}) {
|
|
|
763
731
|
|
|
764
732
|
await ensureAuthenticated();
|
|
765
733
|
const slug = resolveSlug();
|
|
734
|
+
const log = (...args) => { if (!options.json) console.log(...args); };
|
|
735
|
+
const logErr = (...args) => { if (!options.json) console.error(...args); };
|
|
766
736
|
|
|
767
|
-
|
|
737
|
+
// Validate mutually exclusive flags
|
|
738
|
+
if (options.promote && options.stage) {
|
|
739
|
+
logErr('\n❌ --promote and --stage are mutually exclusive.\n');
|
|
740
|
+
if (options.json) process.stdout.write(JSON.stringify({ success: false, error: '--promote and --stage are mutually exclusive' }) + '\n');
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Determine promote_mode and preview_force
|
|
745
|
+
const promoteMode = options.promote ? 'promote' : options.stage ? 'stage' : 'auto';
|
|
746
|
+
const previewForce = !!options.preview;
|
|
747
|
+
// --preview alone = preview_only deploy (production pointer untouched, preview always moved)
|
|
748
|
+
// --preview + --promote/--stage = full production deploy + always move preview pointer
|
|
749
|
+
const previewOnly = previewForce && promoteMode === 'auto';
|
|
750
|
+
|
|
751
|
+
log('\n📦 Building...\n');
|
|
768
752
|
|
|
769
753
|
// Step 1: Build
|
|
770
754
|
const { build } = await import('./build.js');
|
|
@@ -773,7 +757,8 @@ export async function deploy(options = {}) {
|
|
|
773
757
|
// Step 2: Verify dist/ exists
|
|
774
758
|
const distPath = path.join(process.cwd(), 'dist');
|
|
775
759
|
if (!fs.existsSync(distPath)) {
|
|
776
|
-
|
|
760
|
+
logErr('\n❌ Build did not produce a dist/ directory.\n');
|
|
761
|
+
if (options.json) process.stdout.write(JSON.stringify({ success: false, error: 'Build did not produce a dist/ directory' }) + '\n');
|
|
777
762
|
process.exit(1);
|
|
778
763
|
}
|
|
779
764
|
|
|
@@ -786,13 +771,28 @@ export async function deploy(options = {}) {
|
|
|
786
771
|
await zipDirectory(distPath, zipPath);
|
|
787
772
|
|
|
788
773
|
// Step 5: Upload
|
|
789
|
-
|
|
790
|
-
|
|
774
|
+
let modeLabel;
|
|
775
|
+
if (previewOnly) {
|
|
776
|
+
modeLabel = 'preview only';
|
|
777
|
+
} else if (options.promote && options.preview) {
|
|
778
|
+
modeLabel = 'force-promote + preview';
|
|
779
|
+
} else if (options.stage && options.preview) {
|
|
780
|
+
modeLabel = 'staged + preview';
|
|
781
|
+
} else if (options.promote) {
|
|
782
|
+
modeLabel = 'force-promote';
|
|
783
|
+
} else if (options.stage) {
|
|
784
|
+
modeLabel = 'staged';
|
|
785
|
+
} else {
|
|
786
|
+
modeLabel = 'production';
|
|
787
|
+
}
|
|
788
|
+
log(`\nDeploying ${slug}${displayOrg} [${modeLabel}]...\n`);
|
|
791
789
|
|
|
792
790
|
const formData = new FormData();
|
|
793
791
|
const zipBuffer = fs.readFileSync(zipPath);
|
|
794
792
|
formData.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'deploy.zip');
|
|
795
793
|
formData.append('orgId', orgId);
|
|
794
|
+
formData.append('promote_mode', promoteMode);
|
|
795
|
+
formData.append('preview_force', String(previewForce));
|
|
796
796
|
|
|
797
797
|
if (options.message) {
|
|
798
798
|
formData.append('message', options.message);
|
|
@@ -803,7 +803,7 @@ export async function deploy(options = {}) {
|
|
|
803
803
|
formData.append('password', pw);
|
|
804
804
|
}
|
|
805
805
|
|
|
806
|
-
const queryString =
|
|
806
|
+
const queryString = previewOnly ? '?mode=preview' : '';
|
|
807
807
|
|
|
808
808
|
const makeRequest = async (_isRetry = false) => {
|
|
809
809
|
const token = readCredentials()?.token;
|
|
@@ -817,29 +817,33 @@ export async function deploy(options = {}) {
|
|
|
817
817
|
|
|
818
818
|
const result = await makeRequest();
|
|
819
819
|
|
|
820
|
-
// Step 6:
|
|
820
|
+
// Step 6: Stamp cloudId + orgId into .coursecoderc.json (committed, shared with team)
|
|
821
821
|
const finalCourseId = result.courseId || courseId;
|
|
822
|
-
|
|
822
|
+
writeRcConfig({
|
|
823
|
+
cloudId: finalCourseId,
|
|
823
824
|
orgId: result.orgId || orgId,
|
|
824
|
-
courseId: finalCourseId,
|
|
825
|
-
slug,
|
|
826
825
|
});
|
|
827
826
|
|
|
828
|
-
// Stamp cloudId into .coursecoderc.json (committed, shared with team)
|
|
829
|
-
const rc = readRcConfig();
|
|
830
|
-
if (finalCourseId && (!rc || rc.cloudId !== finalCourseId)) {
|
|
831
|
-
writeRcCloudId(finalCourseId);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
827
|
// Step 7: Display result
|
|
835
|
-
if (
|
|
828
|
+
if (options.json) {
|
|
829
|
+
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
830
|
+
} else if (result.mode === 'preview') {
|
|
831
|
+
// preview_only=true deployment (--preview alone)
|
|
836
832
|
console.log(`✓ Preview deployed (${result.fileCount} files)`);
|
|
837
|
-
console.log(` URL: ${result.url}`);
|
|
838
|
-
|
|
833
|
+
console.log(` Preview URL: ${result.url}`);
|
|
834
|
+
console.log(` Dashboard: ${result.dashboardUrl}`);
|
|
839
835
|
} else {
|
|
840
|
-
|
|
841
|
-
const
|
|
842
|
-
console.log(
|
|
836
|
+
const prodTag = result.promoted ? 'live' : 'staged';
|
|
837
|
+
const previewTag = result.previewPromoted ? ' + preview' : '';
|
|
838
|
+
console.log(`✓ Deployed (${result.fileCount} files) — ${prodTag}${previewTag}`);
|
|
839
|
+
if (!result.promoted) {
|
|
840
|
+
console.log(` Production pointer not updated. Promote from Deploy History or run:`);
|
|
841
|
+
console.log(` coursecode promote --production`);
|
|
842
|
+
}
|
|
843
|
+
if (result.previewPromoted) {
|
|
844
|
+
console.log(` Preview pointer updated.`);
|
|
845
|
+
}
|
|
846
|
+
console.log(` Dashboard: ${result.dashboardUrl}`);
|
|
843
847
|
}
|
|
844
848
|
console.log('');
|
|
845
849
|
|
|
@@ -847,15 +851,109 @@ export async function deploy(options = {}) {
|
|
|
847
851
|
try { fs.unlinkSync(zipPath); } catch { /* fine */ }
|
|
848
852
|
}
|
|
849
853
|
|
|
854
|
+
/**
|
|
855
|
+
* coursecode promote — promote a deployment to production or preview
|
|
856
|
+
*/
|
|
857
|
+
export async function promote(options = {}) {
|
|
858
|
+
await ensureAuthenticated();
|
|
859
|
+
const slug = resolveSlug();
|
|
860
|
+
const rcConfig = readRcConfig();
|
|
861
|
+
|
|
862
|
+
// Validate target flag
|
|
863
|
+
if (!options.production && !options.preview) {
|
|
864
|
+
console.error('\n❌ Specify a target: --production or --preview\n');
|
|
865
|
+
process.exit(1);
|
|
866
|
+
}
|
|
867
|
+
if (options.production && options.preview) {
|
|
868
|
+
console.error('\n❌ Specify only one target: --production or --preview\n');
|
|
869
|
+
process.exit(1);
|
|
870
|
+
}
|
|
871
|
+
const target = options.production ? 'production' : 'preview';
|
|
872
|
+
|
|
873
|
+
// Resolve deployment ID interactively if not provided
|
|
874
|
+
let deploymentId = options.deployment;
|
|
875
|
+
if (!deploymentId) {
|
|
876
|
+
const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
|
|
877
|
+
const makeVersionsRequest = async (_isRetry = false) => {
|
|
878
|
+
const token = readCredentials()?.token;
|
|
879
|
+
const res = await cloudFetch(
|
|
880
|
+
`/api/cli/courses/${encodeURIComponent(slug)}/versions${orgQuery}`,
|
|
881
|
+
{},
|
|
882
|
+
token
|
|
883
|
+
);
|
|
884
|
+
return handleResponse(res, { retryFn: makeVersionsRequest, _isRetry });
|
|
885
|
+
};
|
|
886
|
+
const data = await makeVersionsRequest();
|
|
887
|
+
const deployments = data.deployments ?? [];
|
|
888
|
+
|
|
889
|
+
if (deployments.length === 0) {
|
|
890
|
+
console.error('\n❌ No deployments found for this course.\n');
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
console.log(`\n Deployments for ${slug}:\n`);
|
|
895
|
+
deployments.slice(0, 10).forEach((d, i) => {
|
|
896
|
+
const marker = d.id === data.production_deployment_id
|
|
897
|
+
? ' [production]'
|
|
898
|
+
: d.id === data.preview_deployment_id
|
|
899
|
+
? ' [preview]'
|
|
900
|
+
: '';
|
|
901
|
+
console.log(` ${i + 1}. ${new Date(d.created_at).toLocaleString()} — ${d.file_count} files${marker}`);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const answer = await prompt('\n Which deployment to promote? ');
|
|
905
|
+
const idx = parseInt(answer, 10) - 1;
|
|
906
|
+
if (idx < 0 || idx >= deployments.length) {
|
|
907
|
+
console.error('\n❌ Invalid selection.\n');
|
|
908
|
+
process.exit(1);
|
|
909
|
+
}
|
|
910
|
+
deploymentId = deployments[idx].id;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const reason = options.message || `Promoted to ${target} via CLI`;
|
|
914
|
+
|
|
915
|
+
const makeRequest = async (_isRetry = false) => {
|
|
916
|
+
const token = readCredentials()?.token;
|
|
917
|
+
const res = await cloudFetch(
|
|
918
|
+
`/api/cli/courses/${encodeURIComponent(slug)}/promote`,
|
|
919
|
+
{
|
|
920
|
+
method: 'POST',
|
|
921
|
+
headers: { 'Content-Type': 'application/json' },
|
|
922
|
+
body: JSON.stringify({ deployment_id: deploymentId, target, reason }),
|
|
923
|
+
},
|
|
924
|
+
token
|
|
925
|
+
);
|
|
926
|
+
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const result = await makeRequest();
|
|
930
|
+
|
|
931
|
+
if (options.json) {
|
|
932
|
+
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (result.already_promoted) {
|
|
937
|
+
console.log(`\n Already the active ${target} deployment. Nothing to do.\n`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
console.log(`\n✓ Promoted to ${target}`);
|
|
942
|
+
if (result.url) {
|
|
943
|
+
console.log(` Preview URL: ${result.url}`);
|
|
944
|
+
}
|
|
945
|
+
console.log('');
|
|
946
|
+
}
|
|
947
|
+
|
|
850
948
|
/**
|
|
851
949
|
* coursecode status — show deployment status for current course
|
|
852
950
|
*/
|
|
853
|
-
export async function status() {
|
|
951
|
+
export async function status(options = {}) {
|
|
854
952
|
await ensureAuthenticated();
|
|
855
953
|
const slug = resolveSlug();
|
|
856
954
|
|
|
857
|
-
const
|
|
858
|
-
const orgQuery =
|
|
955
|
+
const rcConfig = readRcConfig();
|
|
956
|
+
const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
|
|
859
957
|
|
|
860
958
|
const makeRequest = async (_isRetry = false) => {
|
|
861
959
|
const token = readCredentials()?.token;
|
|
@@ -869,8 +967,20 @@ export async function status() {
|
|
|
869
967
|
|
|
870
968
|
const data = await makeRequest();
|
|
871
969
|
|
|
970
|
+
if (options.json) {
|
|
971
|
+
process.stdout.write(JSON.stringify(data) + '\n');
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
872
975
|
console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
|
|
873
976
|
|
|
977
|
+
if (data.source_type === 'github' && data.github_repo) {
|
|
978
|
+
console.log(`Source: GitHub — ${data.github_repo}`);
|
|
979
|
+
console.log(` (changes deploy via GitHub, not direct upload)`);
|
|
980
|
+
} else if (data.source_type) {
|
|
981
|
+
console.log(`Source: ${data.source_type}`);
|
|
982
|
+
}
|
|
983
|
+
|
|
874
984
|
if (data.lastDeploy) {
|
|
875
985
|
console.log(`Last deploy: ${formatDate(data.lastDeploy)} (${data.lastDeployFileCount} files, ${formatBytes(data.lastDeploySize)})`);
|
|
876
986
|
} else {
|
|
@@ -888,26 +998,91 @@ export async function status() {
|
|
|
888
998
|
console.log('');
|
|
889
999
|
}
|
|
890
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* coursecode delete — remove course record from CourseCode Cloud.
|
|
1003
|
+
*
|
|
1004
|
+
* Cloud-only: this command does not delete local files. CLI users can remove
|
|
1005
|
+
* their project directory themselves; the Desktop handles local deletion via
|
|
1006
|
+
* shell.trashItem after calling this command.
|
|
1007
|
+
*
|
|
1008
|
+
* Response includes source_type + github_repo so callers can warn the user
|
|
1009
|
+
* when the deleted course was GitHub-linked (repo is unaffected, integration
|
|
1010
|
+
* is only disconnected on the Cloud side).
|
|
1011
|
+
*/
|
|
1012
|
+
export async function deleteCourse(options = {}) {
|
|
1013
|
+
await ensureAuthenticated();
|
|
1014
|
+
const slug = resolveSlug();
|
|
1015
|
+
const log = (...args) => { if (!options.json) console.log(...args); };
|
|
1016
|
+
|
|
1017
|
+
const rcConfig = readRcConfig();
|
|
1018
|
+
if (!rcConfig?.cloudId) {
|
|
1019
|
+
if (options.json) {
|
|
1020
|
+
process.stdout.write(JSON.stringify({ success: false, error: 'Course has not been deployed to Cloud. Nothing to delete.' }) + '\n');
|
|
1021
|
+
} else {
|
|
1022
|
+
console.error('\n❌ Course has not been deployed to Cloud. Nothing to delete.\n');
|
|
1023
|
+
}
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
|
|
1028
|
+
|
|
1029
|
+
if (!options.force && !options.json) {
|
|
1030
|
+
const answer = await prompt(`\n Delete "${slug}" from CourseCode Cloud? This cannot be undone. [y/N] `);
|
|
1031
|
+
if (answer.toLowerCase() !== 'y') {
|
|
1032
|
+
console.log(' Cancelled.\n');
|
|
1033
|
+
process.exit(0);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
log(`\nDeleting ${slug} from Cloud...\n`);
|
|
1038
|
+
|
|
1039
|
+
const makeRequest = async (_isRetry = false) => {
|
|
1040
|
+
const token = readCredentials()?.token;
|
|
1041
|
+
const res = await cloudFetch(
|
|
1042
|
+
`/api/cli/courses/${encodeURIComponent(slug)}${orgQuery}`,
|
|
1043
|
+
{
|
|
1044
|
+
method: 'DELETE',
|
|
1045
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1046
|
+
body: JSON.stringify({ cloudId: rcConfig.cloudId }),
|
|
1047
|
+
},
|
|
1048
|
+
token
|
|
1049
|
+
);
|
|
1050
|
+
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
const result = await makeRequest();
|
|
1054
|
+
|
|
1055
|
+
if (options.json) {
|
|
1056
|
+
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
console.log(`✓ "${slug}" deleted from CourseCode Cloud.`);
|
|
1061
|
+
if (result.source_type === 'github' && result.github_repo) {
|
|
1062
|
+
console.log(`\n ⚠️ This course was linked to GitHub (${result.github_repo}).`);
|
|
1063
|
+
console.log(` The GitHub integration has been disconnected.`);
|
|
1064
|
+
console.log(` Your repository and its files are unaffected.`);
|
|
1065
|
+
}
|
|
1066
|
+
console.log('');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
891
1069
|
// =============================================================================
|
|
892
1070
|
// ZIP HELPER
|
|
893
1071
|
// =============================================================================
|
|
894
1072
|
|
|
895
1073
|
/**
|
|
896
|
-
* Zip a directory's contents using
|
|
897
|
-
* Falls back to a tar+gzip approach if zip isn't available.
|
|
1074
|
+
* Zip a directory's contents using archiver (cross-platform, no native tools needed).
|
|
898
1075
|
*/
|
|
899
|
-
function zipDirectory(sourceDir, outputPath) {
|
|
1076
|
+
async function zipDirectory(sourceDir, outputPath) {
|
|
1077
|
+
const archiver = (await import('archiver')).default;
|
|
900
1078
|
return new Promise((resolve, reject) => {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
);
|
|
1079
|
+
const output = fs.createWriteStream(outputPath);
|
|
1080
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
1081
|
+
|
|
1082
|
+
output.on('close', () => resolve());
|
|
1083
|
+
archive.on('error', reject);
|
|
1084
|
+
archive.pipe(output);
|
|
1085
|
+
archive.directory(sourceDir, false);
|
|
1086
|
+
archive.finalize();
|
|
912
1087
|
});
|
|
913
1088
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coursecode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Multi-format course authoring framework with CLI tools (SCORM 2004, SCORM 1.2, cmi5, LTI 1.3)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
101
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
102
|
+
"archiver": "^7.0.1",
|
|
102
103
|
"commander": "^14.0.3",
|
|
103
104
|
"lz-string": "^1.5.0",
|
|
104
105
|
"mammoth": "^1.8.0",
|
|
@@ -108,6 +109,7 @@
|
|
|
108
109
|
"pdf2json": "^4.0.2",
|
|
109
110
|
"pdf2md": "^1.0.2",
|
|
110
111
|
"puppeteer-core": "^24.37.2",
|
|
112
|
+
"win-ca": "^3.5.1",
|
|
111
113
|
"ws": "^8.18.0"
|
|
112
114
|
},
|
|
113
115
|
"devDependencies": {
|
|
@@ -115,7 +117,6 @@
|
|
|
115
117
|
"@vitest/coverage-v8": "^4.0.18",
|
|
116
118
|
"@xapi/cmi5": "^1.4.0",
|
|
117
119
|
"acorn": "^8.15.0",
|
|
118
|
-
"archiver": "^7.0.1",
|
|
119
120
|
"eslint": "^10.0.0",
|
|
120
121
|
"globals": "^17.3.0",
|
|
121
122
|
"jose": "^6.1.3",
|
|
@@ -124,4 +125,4 @@
|
|
|
124
125
|
"vite-plugin-static-copy": "^3.1.4",
|
|
125
126
|
"vitest": "^4.0.18"
|
|
126
127
|
}
|
|
127
|
-
}
|
|
128
|
+
}
|