incremnt 0.1.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/README.md +66 -0
- package/package.json +17 -0
- package/src/auth.js +319 -0
- package/src/contract.js +40 -0
- package/src/format.js +72 -0
- package/src/index.js +6 -0
- package/src/lib.js +341 -0
- package/src/local.js +59 -0
- package/src/queries.js +335 -0
- package/src/remote.js +161 -0
- package/src/service-url.js +7 -0
- package/src/state.js +129 -0
- package/src/sync-service.js +1165 -0
- package/src/transport.js +56 -0
package/src/lib.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { capabilities, contractVersion, officialCommands, readCommands } from './contract.js';
|
|
4
|
+
import {
|
|
5
|
+
bootstrapSessionFromRemoteBaseUrl,
|
|
6
|
+
bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
|
|
7
|
+
bootstrapSessionFromRemoteBaseUrlWithEmail,
|
|
8
|
+
bootstrapSessionFromSnapshot,
|
|
9
|
+
fetchRemoteAuthConfig,
|
|
10
|
+
importSessionFile
|
|
11
|
+
} from './auth.js';
|
|
12
|
+
import { clearSessionState, isSessionExpired, readSessionState, resolveConfigDir } from './state.js';
|
|
13
|
+
import { createTransport } from './transport.js';
|
|
14
|
+
import { formatPretty } from './format.js';
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const commandTokens = [];
|
|
18
|
+
const rest = [];
|
|
19
|
+
let parsingOptions = false;
|
|
20
|
+
|
|
21
|
+
for (const token of argv) {
|
|
22
|
+
if (!parsingOptions && !token.startsWith('--')) {
|
|
23
|
+
commandTokens.push(token);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
parsingOptions = true;
|
|
28
|
+
rest.push(token);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const command = commandTokens.join(' ');
|
|
32
|
+
const options = {};
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
35
|
+
const token = rest[index];
|
|
36
|
+
if (!token.startsWith('--')) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const key = token.slice(2);
|
|
41
|
+
const next = rest[index + 1];
|
|
42
|
+
if (next === undefined || next.startsWith('--')) {
|
|
43
|
+
options[key] = true;
|
|
44
|
+
} else {
|
|
45
|
+
options[key] = next;
|
|
46
|
+
index += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { command, options };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runCli(argv, stdout, stderr) {
|
|
54
|
+
const { command, options } = parseArgs(argv);
|
|
55
|
+
const normalizedCommand = ({
|
|
56
|
+
'sessions list': 'session-insights',
|
|
57
|
+
'sessions show': 'session-show',
|
|
58
|
+
'programs list': 'program-list',
|
|
59
|
+
'programs current': 'program-summary',
|
|
60
|
+
'exercises history': 'exercise-history',
|
|
61
|
+
'sessions compare': 'planned-vs-actual',
|
|
62
|
+
'sessions explain': 'why-did-this-change',
|
|
63
|
+
insights: 'session-insights',
|
|
64
|
+
history: 'exercise-history',
|
|
65
|
+
prs: 'records',
|
|
66
|
+
program: 'program-summary',
|
|
67
|
+
compare: 'planned-vs-actual',
|
|
68
|
+
explain: 'why-did-this-change'
|
|
69
|
+
})[command] ?? command;
|
|
70
|
+
|
|
71
|
+
if (!command) {
|
|
72
|
+
stderr.write('Usage: incremnt <sessions list|sessions show|sessions compare|sessions explain|programs list|programs current|exercises history|records|status|contract|login|logout> [options]\n');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sessionState = await readSessionState();
|
|
77
|
+
const transport = await createTransport(options, sessionState);
|
|
78
|
+
|
|
79
|
+
if (normalizedCommand === 'status') {
|
|
80
|
+
stdout.write(`${JSON.stringify({
|
|
81
|
+
contractVersion,
|
|
82
|
+
capabilities,
|
|
83
|
+
mode: transport.kind,
|
|
84
|
+
config: {
|
|
85
|
+
dir: resolveConfigDir(),
|
|
86
|
+
sessionPath: sessionState.path
|
|
87
|
+
},
|
|
88
|
+
auth: {
|
|
89
|
+
loggedIn: Boolean(sessionState.session),
|
|
90
|
+
sessionExists: sessionState.exists,
|
|
91
|
+
sessionSchemaVersion: sessionState.session?.version ?? null,
|
|
92
|
+
expired: isSessionExpired(sessionState.session),
|
|
93
|
+
error: sessionState.error,
|
|
94
|
+
account: sessionState.session?.account ?? null
|
|
95
|
+
},
|
|
96
|
+
remote: {
|
|
97
|
+
bootstrap: transport.kind === 'remote' ? transport.bootstrap ?? false : false,
|
|
98
|
+
source: transport.kind === 'remote' ? transport.source ?? null : null,
|
|
99
|
+
baseUrl: transport.kind === 'remote' ? transport.baseUrl ?? null : null,
|
|
100
|
+
contractVersion: transport.kind === 'remote' ? transport.contractVersion ?? null : null,
|
|
101
|
+
capabilities: transport.kind === 'remote' ? transport.capabilities ?? null : null,
|
|
102
|
+
fixturePath: transport.kind === 'remote' ? transport.fixturePath ?? null : null
|
|
103
|
+
},
|
|
104
|
+
snapshot: {
|
|
105
|
+
source: transport.snapshotSource?.source ?? null,
|
|
106
|
+
path: transport.snapshotSource?.path ?? null
|
|
107
|
+
}
|
|
108
|
+
}, null, 2)}\n`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (normalizedCommand === 'contract') {
|
|
113
|
+
stdout.write(`${JSON.stringify({
|
|
114
|
+
contractVersion,
|
|
115
|
+
binary: 'incremnt',
|
|
116
|
+
capabilities,
|
|
117
|
+
officialCommands
|
|
118
|
+
}, null, 2)}\n`);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (normalizedCommand === 'logout') {
|
|
123
|
+
try {
|
|
124
|
+
const result = await clearSessionState();
|
|
125
|
+
stdout.write(`${JSON.stringify({
|
|
126
|
+
ok: true,
|
|
127
|
+
cleared: result.cleared,
|
|
128
|
+
sessionPath: result.path
|
|
129
|
+
}, null, 2)}\n`);
|
|
130
|
+
return 0;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
stderr.write(`${error.message}\n`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (normalizedCommand === 'login') {
|
|
138
|
+
const loginBaseUrl = resolveLoginBaseUrl(options, sessionState);
|
|
139
|
+
|
|
140
|
+
if (loginBaseUrl) {
|
|
141
|
+
if (options.token && options.email) {
|
|
142
|
+
stderr.write('Pass either --token or --email with --base-url, not both\n');
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!options.token && !options.email) {
|
|
147
|
+
try {
|
|
148
|
+
const authConfig = await fetchRemoteAuthConfig(loginBaseUrl);
|
|
149
|
+
const result = await bootstrapSessionFromRemoteBaseUrlWithDeviceFlow(loginBaseUrl, {
|
|
150
|
+
authConfig,
|
|
151
|
+
onChallenge: async (challenge, auth) => {
|
|
152
|
+
const baseUrlNormalized = loginBaseUrl.replace(/\/$/, '');
|
|
153
|
+
const userCodeParam = encodeURIComponent(challenge.userCode);
|
|
154
|
+
const providers = configuredAuthProviders(auth);
|
|
155
|
+
|
|
156
|
+
// Skip the approval form and go straight to the provider when there's exactly one
|
|
157
|
+
let verificationUrl;
|
|
158
|
+
if (providers.length === 1 && auth?.providers?.apple?.configured) {
|
|
159
|
+
verificationUrl = `${baseUrlNormalized}/auth/apple/start?userCode=${userCodeParam}`;
|
|
160
|
+
} else if (providers.length === 1 && auth?.providers?.google?.configured) {
|
|
161
|
+
verificationUrl = `${baseUrlNormalized}/auth/google/start?userCode=${userCodeParam}`;
|
|
162
|
+
} else {
|
|
163
|
+
verificationUrl = `${baseUrlNormalized}${challenge.verificationUri ?? '/auth/device/approve'}?userCode=${userCodeParam}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
stderr.write('Signing in...\n');
|
|
167
|
+
const opened = await maybeOpenBrowser(verificationUrl);
|
|
168
|
+
if (opened) {
|
|
169
|
+
stderr.write('Opened your browser to sign in.\n');
|
|
170
|
+
} else {
|
|
171
|
+
stderr.write(`Open this URL to sign in: ${verificationUrl}\n`);
|
|
172
|
+
}
|
|
173
|
+
if (providers.length > 1) {
|
|
174
|
+
stderr.write(`Continue with one of: ${providers.join(', ')}.\n`);
|
|
175
|
+
} else if (providers.length === 0) {
|
|
176
|
+
stderr.write('If you are using the local dev sync service, run its approve-device helper with that code.\n');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
stdout.write(`${JSON.stringify({
|
|
181
|
+
ok: true,
|
|
182
|
+
sessionPath: result.path,
|
|
183
|
+
account: result.session.account,
|
|
184
|
+
remoteContractVersion: result.session.transport?.contractVersion ?? null,
|
|
185
|
+
transport: result.session.transport
|
|
186
|
+
}, null, 2)}\n`);
|
|
187
|
+
return 0;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
stderr.write(`${error.message}\n`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (options.email) {
|
|
195
|
+
try {
|
|
196
|
+
const result = await bootstrapSessionFromRemoteBaseUrlWithEmail(loginBaseUrl, options.email, options['user-id']);
|
|
197
|
+
stdout.write(`${JSON.stringify({
|
|
198
|
+
ok: true,
|
|
199
|
+
sessionPath: result.path,
|
|
200
|
+
account: result.session.account,
|
|
201
|
+
remoteContractVersion: result.session.transport?.contractVersion ?? null,
|
|
202
|
+
transport: result.session.transport
|
|
203
|
+
}, null, 2)}\n`);
|
|
204
|
+
return 0;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
stderr.write(`${error.message}\n`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const result = await bootstrapSessionFromRemoteBaseUrl(loginBaseUrl, options.token);
|
|
213
|
+
stdout.write(`${JSON.stringify({
|
|
214
|
+
ok: true,
|
|
215
|
+
sessionPath: result.path,
|
|
216
|
+
account: result.session.account,
|
|
217
|
+
remoteContractVersion: result.session.transport?.contractVersion ?? null,
|
|
218
|
+
transport: result.session.transport
|
|
219
|
+
}, null, 2)}\n`);
|
|
220
|
+
return 0;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
stderr.write(`${error.message}\n`);
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (options.snapshot) {
|
|
228
|
+
try {
|
|
229
|
+
const result = await bootstrapSessionFromSnapshot(options.snapshot);
|
|
230
|
+
stdout.write(`${JSON.stringify({
|
|
231
|
+
ok: true,
|
|
232
|
+
sessionPath: result.path,
|
|
233
|
+
account: result.session.account,
|
|
234
|
+
transport: result.session.transport
|
|
235
|
+
}, null, 2)}\n`);
|
|
236
|
+
return 0;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
stderr.write(`${error.message}\n`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (options['session-file']) {
|
|
244
|
+
try {
|
|
245
|
+
const result = await importSessionFile(options['session-file']);
|
|
246
|
+
stdout.write(`${JSON.stringify({
|
|
247
|
+
ok: true,
|
|
248
|
+
sessionPath: result.path,
|
|
249
|
+
account: result.session.account
|
|
250
|
+
}, null, 2)}\n`);
|
|
251
|
+
return 0;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
stderr.write(`${error.message}\n`);
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
stderr.write('Pass --base-url, --snapshot, or --session-file to login, or set INCREMNT_BASE_URL.\n');
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!readCommands.has(normalizedCommand)) {
|
|
263
|
+
stderr.write(`Unknown command: ${command}\n`);
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const payload = await transport.executeReadCommand(normalizedCommand, options);
|
|
269
|
+
const pretty = options.pretty ? formatPretty(normalizedCommand, payload) : null;
|
|
270
|
+
stdout.write(pretty ? `${pretty}\n` : `${JSON.stringify(payload, null, 2)}\n`);
|
|
271
|
+
return 0;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
stderr.write(`${error.message}\n`);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function resolveLoginBaseUrl(options, sessionState) {
|
|
279
|
+
return options['base-url']
|
|
280
|
+
?? process.env.INCREMNT_BASE_URL
|
|
281
|
+
?? sessionState.session?.transport?.baseUrl
|
|
282
|
+
?? null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function maybeOpenBrowser(url) {
|
|
286
|
+
if (process.env.INCREMNT_DISABLE_BROWSER === '1') {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (process.env.INCREMNT_BROWSER_CAPTURE_FILE) {
|
|
291
|
+
await fs.writeFile(process.env.INCREMNT_BROWSER_CAPTURE_FILE, url);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const command = browserCommandForPlatform();
|
|
296
|
+
if (!command) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const child = spawn(command.bin, [...command.args, url], {
|
|
302
|
+
stdio: 'ignore',
|
|
303
|
+
detached: true
|
|
304
|
+
});
|
|
305
|
+
child.unref();
|
|
306
|
+
return true;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function browserCommandForPlatform() {
|
|
313
|
+
if (process.platform === 'darwin') {
|
|
314
|
+
return { bin: 'open', args: [] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (process.platform === 'linux') {
|
|
318
|
+
return { bin: 'xdg-open', args: [] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (process.platform === 'win32') {
|
|
322
|
+
return { bin: 'cmd', args: ['/c', 'start', ''] };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function configuredAuthProviders(auth) {
|
|
329
|
+
const providers = auth?.providers ?? {};
|
|
330
|
+
const labels = [];
|
|
331
|
+
|
|
332
|
+
if (providers.apple?.configured) {
|
|
333
|
+
labels.push('Apple');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (providers.google?.configured) {
|
|
337
|
+
labels.push('Google');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return labels;
|
|
341
|
+
}
|
package/src/local.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function readSnapshot(inputPath) {
|
|
6
|
+
const raw = await fs.readFile(inputPath, 'utf8');
|
|
7
|
+
return JSON.parse(raw);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function mostRecentDownloadSnapshot() {
|
|
11
|
+
const downloadsDir = path.join(os.homedir(), 'Downloads');
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const entries = await fs.readdir(downloadsDir, { withFileTypes: true });
|
|
15
|
+
const matchingFiles = entries
|
|
16
|
+
.filter((entry) => entry.isFile() && /^onemore-developer-snapshot\.onemore(?: \d+)?\.json$/i.test(entry.name))
|
|
17
|
+
.map((entry) => path.join(downloadsDir, entry.name));
|
|
18
|
+
|
|
19
|
+
if (matchingFiles.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const withStats = await Promise.all(
|
|
24
|
+
matchingFiles.map(async (candidate) => ({
|
|
25
|
+
candidate,
|
|
26
|
+
stat: await fs.stat(candidate)
|
|
27
|
+
}))
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return withStats
|
|
31
|
+
.sort((lhs, rhs) => rhs.stat.mtimeMs - lhs.stat.mtimeMs)[0]
|
|
32
|
+
.candidate;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveSnapshotSource(explicitPath) {
|
|
39
|
+
const downloadSnapshot = await mostRecentDownloadSnapshot();
|
|
40
|
+
const candidates = [
|
|
41
|
+
explicitPath ? { path: explicitPath, source: 'input' } : null,
|
|
42
|
+
process.env.INCREMNT_SNAPSHOT ? { path: process.env.INCREMNT_SNAPSHOT, source: 'env:INCREMNT_SNAPSHOT' } : null,
|
|
43
|
+
process.env.ONEMORE_SNAPSHOT ? { path: process.env.ONEMORE_SNAPSHOT, source: 'env:ONEMORE_SNAPSHOT' } : null,
|
|
44
|
+
{ path: path.join(process.cwd(), 'onemore-developer-snapshot.onemore.json'), source: 'cwd' },
|
|
45
|
+
{ path: path.join(os.homedir(), 'Library', 'Application Support', 'OneMore', 'onemore-developer-snapshot.onemore.json'), source: 'app-support' },
|
|
46
|
+
downloadSnapshot ? { path: downloadSnapshot, source: 'downloads' } : null
|
|
47
|
+
].filter(Boolean);
|
|
48
|
+
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(candidate.path);
|
|
52
|
+
return candidate;
|
|
53
|
+
} catch {
|
|
54
|
+
// Try next candidate.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|