Skip to content

Commit 2f04ede

Browse files
committed
x
1 parent 26f34f7 commit 2f04ede

10 files changed

Lines changed: 987 additions & 37 deletions

File tree

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,10 @@ If you want the guided, button-based workflow, start on the Desktop site:
1010

1111
## Download (Beta)
1212

13-
You can download installers directly here to avoid extra steps:
13+
Use the Desktop site for the current installers, release notes, and checksums:
1414

15-
- **macOS (Apple Silicon + Intel, beta):** [Download `.dmg` (v0.10.0-beta.1)](https://github.com/course-code-framework/coursecode-desktop/releases/download/v0.10.0-beta.1/CourseCode-Desktop-v0.10.0-beta.1-mac.dmg)
16-
- **Windows 10/11 x64 (beta):** [Download `.exe` (v0.10.0-beta.1)](https://github.com/course-code-framework/coursecode-desktop/releases/download/v0.10.0-beta.1/CourseCode-Desktop-v0.10.0-beta.1-win.exe)
17-
- **Release notes + checksums:** [GitHub Release v0.10.0-beta.1](https://github.com/course-code-framework/coursecode-desktop/releases/tag/v0.10.0-beta.1)
15+
- **Latest downloads:** [coursecodedesktop.com/download](https://coursecodedesktop.com/download)
16+
- **GitHub Releases:** [course-code-framework/coursecode-desktop/releases](https://github.com/course-code-framework/coursecode-desktop/releases)
1817

1918
## Temporary Beta Note (Unsigned Apps)
2019

USER_GUIDE.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Typical actions:
7474
- `Preview` (start/stop local preview server)
7575
- `Export` (build LMS package locally)
7676
- `Deploy` (publish to CourseCode Cloud, optional)
77+
- Cloud deployment management for linked courses
7778
- open in editor / reveal in Finder / open terminal
7879

7980
### Settings
@@ -114,7 +115,9 @@ Cloud is most helpful when you want to spend less time on packaging and file sha
114115

115116
What Cloud gives you:
116117
- a hosted course version you can access online after deploy
117-
- shareable preview links for reviewers and stakeholders
118+
- a main preview link for reviewers and stakeholders
119+
- password-protected preview sharing by default, with passwordless sharing as an explicit choice
120+
- Production and Preview pointers so you can stage review versions without changing what learners see
118121
- LMS format downloads later (SCORM/cmi5) from the same uploaded build
119122
- simpler updates (redeploy once, then use Cloud for future downloads/sharing)
120123
- cloud-managed runtime services (reporting/channel) without manual endpoint setup
@@ -150,9 +153,39 @@ If you sign in to CourseCode Cloud, Desktop can also support:
150153
Typical non-technical workflow:
151154
1. Build and test in Desktop with `Preview`
152155
2. Click `Deploy` to publish to Cloud
153-
3. Share the Cloud preview link with reviewers
154-
4. Make fixes in Desktop and deploy again
155-
5. Download the LMS package you need from Cloud when approved
156+
3. Keep `Require password` on for the main preview link unless you intentionally want a passwordless review URL
157+
4. Share the Cloud preview link with reviewers
158+
5. Make fixes in Desktop and deploy again
159+
6. Use the Cloud Deployments panel to move the Preview pointer for review or Production pointer when approved
160+
7. Download the LMS package you need from Cloud when approved
161+
162+
### Managing Cloud Deployments in Desktop
163+
164+
For a linked Cloud course, Project Detail includes a Cloud Deployments panel. It is a focused Desktop subset of the Cloud web app.
165+
166+
Use it to:
167+
- create, enable, disable, copy, or open the main preview link
168+
- add, change, or remove the preview password
169+
- extend preview expiry by seven days
170+
- see the current Production and Preview pointer versions
171+
- view recent deployments
172+
- move the Preview pointer to a selected deployment
173+
- move the Production pointer to a selected deployment when the course is not GitHub-linked
174+
175+
The main preview link follows the Preview pointer. That means the shared URL can stay the same while you choose which deployment reviewers see.
176+
177+
Desktop keeps advanced Cloud workflows in the Cloud web app, including multiple pinned stakeholder preview links, cleanup, analytics, and detailed audit exploration.
178+
179+
### GitHub-Linked Courses
180+
181+
If a course is linked to a GitHub repository, production deploys are managed by GitHub. Push to the repo to update Production.
182+
183+
Desktop still supports:
184+
- preview-only deploys
185+
- Preview pointer changes
186+
- main preview link password/expiry management
187+
188+
Desktop disables Production pointer changes for GitHub-linked courses to avoid conflicting with the repository workflow.
156189

157190
Cloud features should always be labeled optional in Desktop UI/docs.
158191

main/cloud-client.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getChildEnv, isLocalMode, getCLISpawnArgs, getProjectCLISpawnArgs } fro
66
import { createLogger } from './logger.js';
77

88
const log = createLogger('cloud');
9+
const CLOUD_BASE_URL = isLocalMode() ? 'http://localhost:3000' : 'https://www.coursecodecloud.com';
910

1011
/**
1112
* Strip ANSI escape codes from a string.
@@ -88,6 +89,31 @@ function createCloudCliError(message, extra = {}) {
8889
return err;
8990
}
9091

92+
async function cloudApiFetch(path, options = {}) {
93+
const token = loadToken();
94+
if (!token) throw new Error('Not authenticated. Please sign in to CourseCode Cloud.');
95+
const res = await fetch(`${CLOUD_BASE_URL}${path}`, {
96+
...options,
97+
headers: {
98+
Authorization: `Bearer ${token}`,
99+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
100+
...(options.headers || {})
101+
}
102+
});
103+
const data = await res.json().catch(() => ({}));
104+
if (!res.ok) {
105+
throw new Error(typeof data?.error === 'string' ? data.error : `Cloud request failed (${res.status})`);
106+
}
107+
return data;
108+
}
109+
110+
async function getCloudCourseRef(projectPath) {
111+
const status = await getDeployStatus(projectPath);
112+
if (!status?.slug) throw new Error('This project is not linked to a Cloud course.');
113+
const orgQuery = status.orgId ? `?orgId=${encodeURIComponent(status.orgId)}` : '';
114+
return { slug: status.slug, orgQuery };
115+
}
116+
91117
// CLI manages credentials at ~/.coursecode/credentials.json (or credentials.local.json in local mode)
92118
function getCredentialsPath() {
93119
const filename = isLocalMode() ? 'credentials.local.json' : 'credentials.json';
@@ -276,6 +302,7 @@ export async function cloudDeploy(projectPath, webContents, options = {}) {
276302
if (options.message) args.push('-m', options.message);
277303
if (options.promote) args.push('--promote');
278304
if (options.preview) args.push('--preview');
305+
if (options.password) args.push('--password', options.password);
279306
if (options.repairBinding) args.push('--repair-binding');
280307
const { command, args: cliArgs } = getProjectCLISpawnArgs(projectPath, args);
281308
const useShell = process.platform === 'win32' && command === 'coursecode';
@@ -448,6 +475,35 @@ export async function updatePreviewLink(projectPath, options = {}) {
448475
return runCLI(args, { cwd: projectPath, projectPath });
449476
}
450477

478+
/**
479+
* List recent immutable deployments for a linked Cloud course.
480+
*/
481+
export async function listDeployments(projectPath) {
482+
if (!loadToken()) throw new Error('Not authenticated. Please sign in to CourseCode Cloud.');
483+
const { slug, orgQuery } = await getCloudCourseRef(projectPath);
484+
return cloudApiFetch(`/api/cli/courses/${encodeURIComponent(slug)}/versions${orgQuery}`);
485+
}
486+
487+
/**
488+
* Move the production or preview pointer to an existing deployment.
489+
*/
490+
export async function promoteDeployment(projectPath, options = {}) {
491+
if (!loadToken()) throw new Error('Not authenticated. Please sign in to CourseCode Cloud.');
492+
if (!options.deploymentId) throw new Error('Deployment ID is required.');
493+
if (options.target !== 'production' && options.target !== 'preview') {
494+
throw new Error('Promotion target must be production or preview.');
495+
}
496+
const { slug } = await getCloudCourseRef(projectPath);
497+
return cloudApiFetch(`/api/cli/courses/${encodeURIComponent(slug)}/promote`, {
498+
method: 'POST',
499+
body: JSON.stringify({
500+
deployment_id: options.deploymentId,
501+
target: options.target,
502+
reason: options.message || `Promoted to ${options.target} from Desktop`
503+
})
504+
});
505+
}
506+
451507
/**
452508
* Delete a course from CourseCode Cloud via CLI.
453509
* Cloud-only: does not touch local files.

main/ipc-handlers.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getAllSettings, saveSetting } from './settings.js';
33
import { scanProjects, createProject, openProject, deleteProject, clearCloudBinding, upgradeProject } from './project-manager.js';
44
import { startPreview, stopPreview, getPreviewStatus, getPreviewPort, getAllPreviewStatuses } from './preview-manager.js';
55
import { exportBuild } from './build-manager.js';
6-
import { cloudLogin, cloudLogout, getCloudUser, cloudDeploy, getDeployStatus, updatePreviewLink } from './cloud-client.js';
6+
import { cloudLogin, cloudLogout, getCloudUser, cloudDeploy, getDeployStatus, updatePreviewLink, listDeployments, promoteDeployment } from './cloud-client.js';
77
import { getSetupStatus, installCLI, getDownloadUrl, getLatestFrameworkVersion } from './cli-installer.js';
88
import {
99
sendMessage, stopGeneration, clearConversation, loadHistory,
@@ -61,6 +61,8 @@ export function registerIpcHandlers() {
6161
handle('cloud:deploy', (e, projectPath, options) => cloudDeploy(projectPath, e.sender, options));
6262
handle('cloud:getDeployStatus', (_e, projectPath, options) => getDeployStatus(projectPath, options));
6363
handle('cloud:updatePreviewLink', (_e, projectPath, options) => updatePreviewLink(projectPath, options));
64+
handle('cloud:listDeployments', (_e, projectPath) => listDeployments(projectPath));
65+
handle('cloud:promoteDeployment', (_e, projectPath, options) => promoteDeployment(projectPath, options));
6466

6567
// --- Settings ---
6668
handle('settings:get', () => getAllSettings());

preload/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ contextBridge.exposeInMainWorld('api', {
5959
deploy: (projectPath, options) => ipcRenderer.invoke('cloud:deploy', projectPath, options),
6060
getDeployStatus: (projectId, options) => ipcRenderer.invoke('cloud:getDeployStatus', projectId, options),
6161
updatePreviewLink: (projectPath, options) => ipcRenderer.invoke('cloud:updatePreviewLink', projectPath, options),
62+
listDeployments: (projectPath) => ipcRenderer.invoke('cloud:listDeployments', projectPath),
63+
promoteDeployment: (projectPath, options) => ipcRenderer.invoke('cloud:promoteDeployment', projectPath, options),
6264
onLoginProgress: (callback) => {
6365
const handler = (_event, data) => callback(data);
6466
ipcRenderer.on('cloud:loginProgress', handler);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script>
2+
import { generatePreviewPassword } from '../lib/preview-password.js';
3+
4+
let {
5+
id = 'preview-password',
6+
requirePassword = $bindable(true),
7+
password = $bindable(''),
8+
disabled = false
9+
} = $props();
10+
11+
function suggestPassword() {
12+
password = generatePreviewPassword();
13+
}
14+
</script>
15+
16+
<div class="preview-password-control">
17+
<label class="preview-password-toggle">
18+
<input type="checkbox" bind:checked={requirePassword} {disabled} />
19+
<span>Require password</span>
20+
</label>
21+
22+
{#if requirePassword}
23+
<div class="preview-password-row">
24+
<input
25+
{id}
26+
class="preview-password-input"
27+
type="text"
28+
bind:value={password}
29+
disabled={disabled}
30+
autocomplete="off"
31+
spellcheck="false"
32+
/>
33+
<button type="button" class="preview-password-suggest" disabled={disabled} onclick={suggestPassword}>
34+
Suggest
35+
</button>
36+
</div>
37+
{/if}
38+
</div>
39+
40+
<style>
41+
.preview-password-control {
42+
display: flex;
43+
flex-direction: column;
44+
gap: 8px;
45+
padding: 10px;
46+
border: 1px solid var(--border);
47+
border-radius: 8px;
48+
background: var(--bg-secondary);
49+
}
50+
51+
.preview-password-toggle {
52+
display: inline-flex;
53+
align-items: center;
54+
gap: 8px;
55+
font-size: 12px;
56+
font-weight: 600;
57+
color: var(--text-primary);
58+
}
59+
60+
.preview-password-toggle input {
61+
margin: 0;
62+
}
63+
64+
.preview-password-row {
65+
display: grid;
66+
grid-template-columns: minmax(0, 1fr) auto;
67+
gap: 8px;
68+
}
69+
70+
.preview-password-input {
71+
min-width: 0;
72+
height: 32px;
73+
padding: 0 10px;
74+
border: 1px solid var(--border);
75+
border-radius: 6px;
76+
background: var(--bg-primary);
77+
color: var(--text-primary);
78+
font: 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
79+
}
80+
81+
.preview-password-input:focus {
82+
outline: none;
83+
border-color: var(--accent);
84+
box-shadow: 0 0 0 2px var(--accent-subtle);
85+
}
86+
87+
.preview-password-suggest {
88+
height: 32px;
89+
padding: 0 10px;
90+
border: 1px solid var(--border);
91+
border-radius: 6px;
92+
background: var(--bg-primary);
93+
color: var(--text-primary);
94+
font-size: 12px;
95+
font-weight: 600;
96+
cursor: pointer;
97+
}
98+
99+
.preview-password-suggest:hover:not(:disabled) {
100+
background: var(--bg-secondary);
101+
}
102+
103+
.preview-password-suggest:disabled,
104+
.preview-password-input:disabled,
105+
.preview-password-toggle input:disabled + span {
106+
opacity: 0.6;
107+
cursor: not-allowed;
108+
}
109+
</style>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const PASSWORD_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
2+
3+
export function generatePreviewPassword(length = 16) {
4+
const bytes = new Uint8Array(length);
5+
if (globalThis.crypto?.getRandomValues) {
6+
globalThis.crypto.getRandomValues(bytes);
7+
} else {
8+
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
9+
}
10+
return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]).join('');
11+
}

0 commit comments

Comments
 (0)